From 9bf291da65a35ec974147f019051dcc17f9328d7 Mon Sep 17 00:00:00 2001 From: Stefan Karpinski Date: Tue, 10 Dec 2019 02:26:46 -0500 Subject: [PATCH] Pkg Client auth: toward a more standard-worth protocol Use names from https://tools.ietf.org/html/rfc6749#section-5.1: - access_token: an HTTP Auth Bearer token - expires_in: a relative expiration time - expires_at: an absolute expiration time - refresh_token: an HTTP Auth Bearer token to refresh - refresh_url: where to get a new auth.toml file Change the auth file name to `auth.toml` from `token.toml` Only send the access_token for regular requests Only send the refresh_token for refresh requests --- src/PlatformEngines.jl | 168 ++++++++++++++++++++--------------------- 1 file changed, 84 insertions(+), 84 deletions(-) diff --git a/src/PlatformEngines.jl b/src/PlatformEngines.jl index 739d6a11ff..5a613a9a6f 100644 --- a/src/PlatformEngines.jl +++ b/src/PlatformEngines.jl @@ -589,97 +589,98 @@ function parse_tar_list(output::AbstractString) return Sys.iswindows() ? replace.(lines, ['/' => '\\']) : lines end -function get_auth_token(url::AbstractString; verbose::Bool = false) +function get_auth_header(url::AbstractString; verbose::Bool = false) server = pkg_server() server === nothing && return startswith(url, server) || return - # find and parse token file + # find and parse auth file m = match(r"(\w+)://([^\\/]+)$", server) if m === nothing @warn "malformed Pkg server value" server=server return end proto, host = m.captures - # refuse to send auth info over unencrypted connections - lowercase(proto) === "https" || return - token_file = joinpath(depots1(), "servers", host, "token.toml") - isfile(token_file) || return - first_time = true - while true - token_info = try - TOML.parsefile(token_file) - catch err - @warn "malformed auth token file" file=token_file err=err - return - end - # construct auth_token string - auth = "" - if haskey(token_info, "refresh_token") - auth = token_info["refresh_token"] - end - if haskey(token_info, "id_token") - isempty(auth) || (auth *= ":") - auth *= token_info["id_token"] - end - if isempty(auth) - @warn "token file without ID or refresh tokens" file=token_file - return - end - auth_token = base64encode(auth) - first_time || return auth_token - first_time = false - # handle token expiration and refresh - if haskey(token_info, "expires") - expires = token_info["expires"]::Integer - elseif haskey(token_info, "expires_in") - expires = mtime(token_file) - expires += token_info["expires_in"]::Integer - end - # renew token if it will expire within the next hour - time_now = time() - if !@isdefined(expires) || expires > time_now + 3600 - return base64encode(auth_token) - end - if !haskey(token_info, "refresh_url") - if expires ≤ time_now - @warn "expired token without refresh URL" file=token_file - end - return - end - refresh_url = token_info["refresh_url"] - if !startswith(lowercase(refresh_url), "https://") - @warn "ignoring insecure auth refresh URL" url=refresh_url - return - end - verbose && @info "Refreshing expired auth token..." file=token_file - tmp = tempname() - try download(refresh_url, tmp, auth_token=auth_token, verbose=verbose) - catch err - @warn "token refresh failure" file=token_file url=refresh_url err=err - rm(tmp, force=true) - return - end - new_token_info = try TOML.parsefile(tmp) - catch err - @warn "discarding malformed token file" url=refresh_url err=err - rm(tmp, force=true) - return + auth_file = joinpath(depots1(), "servers", host, "auth.toml") + isfile(auth_file) || return + # TODO: check for insecure auth file permissions + if lowercase(proto) != "https" + @warn "refusing to send auth info over insecure connection" url=url + return + end + # parse the auth file + auth_info = try + TOML.parsefile(auth_file) + catch err + @error "malformed auth file" file=auth_file err=err + return + end + # check for an auth token + if !haskey(auth_info, "access_token") + @warn "auth file without access_token field" file=auth_file + return + end + auth_header = "Authorization: Bearer $(auth_info["access_token"])" + # handle token expiration and refresh + expires_at = Inf + if haskey(auth_info, "expires_at") + expires_at = min(expires_at, auth_info["expires_at"]::Integer) + end + if haskey(auth_info, "expires_in") + expires_at = min(expires_at, mtime(auth_file) + auth_info["expires_in"]::Integer) + end + # if token is good until ten minutes from now, use it + time_now = time() + if expires_at ≥ time_now + 10*60 # ten minutes + return auth_header + end + if !haskey(auth_info, "refresh_url") || !haskey(auth_info, "refresh_token") + if expires_at ≤ time_now + @warn "expired auth without refresh keys" file=auth_file end - if haskey(new_token_info, "expires_in") - expires_in = new_token_info["expires_in"] - if expires_in isa Number - expires = floor(Int64, time_now + expires_in) - # overwrite sent expires value; - # this avoids clock skew issues - new_token_info["expires"] = expires - end + # try it anyway since we can't refresh + return auth_header + end + refresh_url = auth_info["refresh_url"] + if !startswith(lowercase(refresh_url), "https://") + @warn "ignoring insecure auth refresh URL" url=refresh_url + return auth_header + end + verbose && @info "Refreshing expired auth token..." file=auth_file + tmp = tempname() + refresh_auth = "Authorization: Bearer $(auth_info["refresh_token"])" + try download(refresh_url, tmp, auth_header=refresh_auth, verbose=verbose) + catch err + @warn "token refresh failure" file=auth_file url=refresh_url err=err + rm(tmp, force=true) + return + end + auth_info = try TOML.parsefile(tmp) + catch err + @warn "discarding malformed auth file" url=refresh_url err=err + rm(tmp, force=true) + return auth_header + end + if !haskey(auth_info, "access_token") + if haskey(auth_info, "refresh_token") + auth_info["refresh_token"] = "*"^64 end - open(tmp, write=true) do io - TOML.print(io, new_token_info, sorted=true) + @warn "discarding auth file without access token" auth=auth_info + rm(tmp, force=true) + return auth_header + end + if haskey(auth_info, "expires_in") + expires_in = auth_info["expires_in"] + if expires_in isa Number + expires_at = floor(Int64, time_now + expires_in) + # overwrite expires_at (avoids clock skew issues) + auth_info["expires_at"] = expires_at end - mv(tmp, token_file, force=true) - # try again from the top end + open(tmp, write=true) do io + TOML.print(io, auth_info, sorted=true) + end + mv(tmp, auth_file, force=true) + return "Authorization: Bearer $(auth_info["access_token"])" end """ @@ -687,7 +688,7 @@ end url::AbstractString, dest::AbstractString; verbose::Bool = false, - auth_token::Union{AbstractString, Nothing} = nothing, + auth_header::Union{AbstractString, Nothing} = nothing, ) Download file located at `url`, store it at `dest`, continuing if `dest` @@ -697,15 +698,14 @@ function download( url::AbstractString, dest::AbstractString; verbose::Bool = false, - auth_token::Union{AbstractString, Nothing} = nothing, + auth_header::Union{AbstractString, Nothing} = nothing, ) - if auth_token === nothing - auth_token = get_auth_token(url, verbose=verbose) + if auth_header === nothing + auth_header = get_auth_header(url, verbose=verbose) end - if auth_token === nothing + if auth_header === nothing download_cmd = gen_download_cmd(url, dest) else - auth_header = "Authorization: Basic $auth_token" download_cmd = gen_download_cmd(url, dest, auth_header) end if verbose