diff --git a/docs/auth-flows.md b/docs/auth-flows.md index db62793..77c9d70 100644 --- a/docs/auth-flows.md +++ b/docs/auth-flows.md @@ -76,6 +76,53 @@ The following information is necessary to start the authentication flow, to know The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this specification are to be interpreted as described in [RFC2119](https://datatracker.ietf.org/doc/html/rfc2119). +### Authentication mechanisms + +PkgAuthentication supports two different authentication mechanisms: + +1. Classic Authentication Flow +2. Device Authentication Flow + +When initiating the authentication flow for a brand new token, PkgAuthentication calls the package server authentication configuration endpoint at + +``` +$(pkg_server)/$(auth_suffix)/configuration +``` + +which can be used to advertise what authentication flows the server supports. + +For a valid implementation of the configuration endpoint, the package server: + +1. MUST always return a `200` HTTP status code. +2. The response body MUST be a valid JSON object (i.e. `{...}`) + +If the response is invalid (non-`200` code or an invalid JSON object), PkgAuthentication will assume that the server only supports the Classic Authentication Flow, and proceed accordingly. + +When device authentication is not supported by the server the response body MAY contain the following JSON data: + +```json +{ + "auth_flows": ["classic"] +} +``` + +In this case, PkgAuthentication will execute the Classic Authentication Flow. + +When device authentication _is_ supported by the server, the response body MUST contain: + +```json +{ + "auth_flows": ["classic", "device"], + "device_token_refresh_url": "https://juliahub.com/auth/renew/token.toml/device/", + "device_authorization_endpoint": "https://auth.juliahub.com/auth/device/code", + "device_token_endpoint": "https://auth.juliahub.com/auth/token" +} +``` + +In this case, PkgAuthentication will execute the Device Authentication Flow. + +Note: URLs in the examples are only representative. Actual URLs may differ. + ### Classic Authentication Flow The classic authentication flow is similar to the [OAuth 2.0 Authorization Code Grant flow](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1), but uses different conventions for endpoints. @@ -141,3 +188,50 @@ The flow goes through the following steps: If PkgAuthentication successfully acquires a token from polling the `/claimtoken` endpoint, it will write the token to the `auth.toml` file. It will write out all the keys and values of the `token` in the `auth.toml` file as TOML. + +### Device Authentication Flow + +Device flow authentication enables an application to authenticate a user by providing a link that can be opened on another device where the user can proceed with authentication. The application will be able to check whether the user has completed authentication on the other device by calling certain APIs. Finally, the application can retrieve the users OAuth token via the same API call. Device flow authentication becomes necessary on devices that do not have a browser based interface for regular login or applications that are not browser based such as command line applications. More details [here](https://datatracker.ietf.org/doc/html/rfc8628). + +The flow goes through the following steps: + +1. A `POST` request MUST be made to the `device_authorization_endpoint` with the headers `Accept: application/json` and `Content-Type: application/x-www-form-urlencoded`. The body of the request MUST contain the url encoded `client_id` and `scope` values. + + The server MUST respond with a 200 status and a body containing a JSON encoded structure. The JSON structure MUST include a `device_code` and a `verification_uri_complete` among other values. Example: + + ```json + { + "device_code": "abcdefghijklmnopqrstuvwxyz1234567890", + "user_code": "FJMC-LPVR", + "verification_uri": "https://juliahub.com/dex/device", + "verification_uri_complete": "https://juliahub.com/dex/device?user_code=FJMC-LPVR", + "expires_in": 300, + "interval": 5 + } + ``` + +2. The client should open `verfication_uri_complete` in the browser so that the user can login and approve the authorization request. The package server SHOULD provide an interface for the user to login and approve or deny the authorization request. + +3. The client should now poll for completion of the authorization request. It can do so by making a `POST` request to the `device_token_endpoint` with the same headers as was used for the `device_authorization_endpoint` call. The body of the request MUST contain the url encoded `client_id` and `scope`. The values of these parameters must match the values sent for `device_authorization_endpoint`. In addition to these two parameters, a `grant_type` and `device_code` parameter must also be included with values `urn:ietf:params:oauth:grant-type:device_code` and the `device_code` response value from the `device_authorization_endpoint` call, respectively. + + While the user hasn't finished responding to the authorization request or has denied the authorization request, the `device_token_endpoint` response status must be `401` or `400`. + + When the user approves the authorization request, the `device_token_endpoint` response status must be 200 with a JSON body containing the `access_token`, `id_token`, `refresh_token` and `expires_in`. Example: + + ```json + { + "access_token": "abcdefghijklmnopqrstuvwxyz1234567890", + "token_type": "bearer", + "expires_in": 86399, + "refresh_token": "abcdefghijklmnopqrstuvwxyz1234567890", + "id_token": "abcdefghijklmnopqrstuvwxyz1234567890" + } + ``` + +4. The client must generate the `auth.toml` file with the above values. The following extra key/values must be added by the client to the auth.toml: + - `expires_at: + ` This value is required to determine whether the token is expired and needs refresh. This is missing in the token response so it must be added by summing the `expires_in` value with the current timestamp. + - `refresh_url` This value is also missing in the device token response but is necessary for refreshing expired tokens. This field must be added with value same as the `device_token_refresh_url` from the package server authentication configuration endpoint response. + +#### Client ID for device authentication flow + +The `client_id` parameter for device authentication can be configured by setting the environment variable `JULIA_PKG_AUTHENTICATION_DEVICE_CLIENT_ID`. This value defaults to `"device"`. diff --git a/src/PkgAuthentication.jl b/src/PkgAuthentication.jl index d981455..ed41cf5 100644 --- a/src/PkgAuthentication.jl +++ b/src/PkgAuthentication.jl @@ -115,14 +115,13 @@ function authenticate(; server = pkg_server() server = rstrip(server, '/') - server = string(server, "/", auth_suffix) local state for i in 1:tries initial = force ? NoAuthentication : NeedAuthentication - state = initial(server) + state = initial(server, auth_suffix) try while !(isa(state, Success) || isa(state, Failure)) @debug "Calling step(::$(typeof(state)))" @@ -148,20 +147,21 @@ or NoAuthentication if not. """ struct NeedAuthentication <: State server::String + auth_suffix::String end -Base.show(io::IO, s::NeedAuthentication) = print(io, "NeedAuthentication($(s.server))") +Base.show(io::IO, s::NeedAuthentication) = print(io, "NeedAuthentication($(s.server), $(s.auth_suffix))") function step(state::NeedAuthentication)::Union{HasToken, NoAuthentication} path = token_path(state.server) if isfile(path) toml = TOML.parsefile(path) if is_token_valid(toml) - return HasToken(state.server, mtime(path), toml) + return HasToken(state.server, state.auth_suffix, mtime(path), toml) else - return NoAuthentication(state.server) + return NoAuthentication(state.server, state.auth_suffix) end else - return NoAuthentication(state.server) + return NoAuthentication(state.server, state.auth_suffix) end end @@ -171,23 +171,117 @@ to RequestLogin, or to Failure otherwise. """ struct NoAuthentication <: State server::String + auth_suffix::String +end +Base.show(io::IO, s::NoAuthentication) = print(io, "NoAuthentication($(s.server), $(s.auth_suffix))") + +function get_device_auth_client_id() + return get(ENV, "JULIA_PKG_AUTHENTICATION_DEVICE_CLIENT_ID", "") +end + +function should_use_device_auth() + return !isempty(get_device_auth_client_id()) +end + +# Query the /auth/configuration endpoint to get the refresh url and +# device authentication endpoints. Returns a Dict with the following +# fields: +# - `auth_flows`::Vector{String}: The authentication mechanisms supported +# by the server. Eg: ["classic", "device"] +# - `device_token_refresh_url`::String: The refresh URL for refreshing the auth +# token +# - `device_authorization_endpoint`::String: The endpoint that must +# be called to initiate device flow authentication. This field is +# only present when device flow is enabled on the server. +# - `device_token_endpoint`::String: The endpoint that should be called to +# retrieve the authentication token after the user has approved +# the authorization request. This field is only present when device +# flow is enabled on the server. +function get_auth_configuration(state::NoAuthentication) + output = IOBuffer() + auth_suffix = isempty(state.auth_suffix) ? "auth" : state.auth_suffix + response = Downloads.request( + "$(state.server)/$(auth_suffix)/configuration", + method = "GET", + output = output, + throw = false, + headers = ["Accept" => "application/json"], + ) + + if response isa Downloads.Response && response.status == 200 + body = nothing + content = String(take!(output)) + try + body = JSON.parse(content) + catch ex + @debug "Request for well known configuration returned: ", content + return Dict{String, Any}() + end + + if body !== nothing + @assert !haskey(body, "auth_flows") || !("device" in body["auth_flows"]) || (haskey(body, "device_authorization_endpoint") && haskey(body, "device_token_endpoint") && haskey(body, "device_token_refresh_url")) + return body + end + end + + return Dict{String, Any}() end -Base.show(io::IO, s::NoAuthentication) = print(io, "NoAuthentication($(s.server))") function step(state::NoAuthentication)::Union{RequestLogin, Failure} - challenge = Random.randstring(32) + auth_config = get_auth_configuration(state) + success, challenge, body_or_response = if "device" in get(auth_config, "auth_flows", []) + fetch_device_code(state, auth_config["device_authorization_endpoint"]) + else + initiate_browser_challenge(state) + end + if success + return RequestLogin(state.server, state.auth_suffix, challenge, body_or_response, get(auth_config, "device_token_endpoint", ""), get(auth_config, "device_token_refresh_url", "")) + else + return HttpError(body_or_response) + end +end + +function fetch_device_code(state::NoAuthentication, device_endpoint::AbstractString) output = IOBuffer() response = Downloads.request( - string(state.server, "/challenge"), + device_endpoint, + method = "POST", + input = IOBuffer("client_id=$(get(ENV, "JULIA_PKG_AUTHENTICATION_DEVICE_CLIENT_ID", "device"))&scope=openid email profile offline_access"), + output = output, + throw = false, + headers = Dict("Accept" => "application/json", "Content-Type" => "application/x-www-form-urlencoded"), + ) + if response isa Downloads.Response && response.status == 200 + body = nothing + content = String(take!(output)) + try + body = JSON.parse(content) + catch ex + @debug "Request for device code returned: ", content + return false, "", response + end + + if body !== nothing + return true, "", body + end + end + return false, "", response +end + +function initiate_browser_challenge(state::NoAuthentication) + output = IOBuffer() + challenge = Random.randstring(32) + response = Downloads.request( + "$(state.server)/$(state.auth_suffix)/challenge", method = "POST", input = IOBuffer(challenge), output = output, throw = false, ) if response isa Downloads.Response && response.status == 200 - return RequestLogin(state.server, challenge, String(take!(output))) + return true, challenge, String(take!(output)) else - return HttpError(response) + return false, challenge, response end end @@ -199,16 +293,17 @@ file), proceeds to Success. Otherwise, proceeds to NeedRefresh. """ struct HasToken <: State server::String + auth_suffix::String mtime::Float64 token::Dict{String, Any} end -Base.show(io::IO, s::HasToken) = print(io, "HasToken($(s.server), $(s.mtime), )") +Base.show(io::IO, s::HasToken) = print(io, "HasToken($(s.server), $(s.auth_suffix), $(s.mtime), )") function step(state::HasToken)::Union{NeedRefresh, Success} expiry = get(state.token, "expires_at", get(state.token, "expires", 0)) expires_in = get(state.token, "expires_in", Inf) if min(expiry, expires_in + state.mtime) < time() - return NeedRefresh(state.server, state.token) + return NeedRefresh(state.server, state.auth_suffix, state.token) else return Success(state.token) end @@ -221,18 +316,18 @@ fails. """ struct NeedRefresh <: State server::String + auth_suffix::String token::Dict{String, Any} end -Base.show(io::IO, s::NeedRefresh) = print(io, "NeedRefresh($(s.server), )") +Base.show(io::IO, s::NeedRefresh) = print(io, "NeedRefresh($(s.server), $(s.auth_suffix), )") function step(state::NeedRefresh)::Union{HasNewToken, NoAuthentication} refresh_token = state.token["refresh_token"] - headers = ["Authorization" => "Bearer $refresh_token"] output = IOBuffer() response = Downloads.request( state.token["refresh_url"], method = "GET", - headers = headers, + headers = ["Authorization" => "Bearer $refresh_token"], output = output, throw = false, ) @@ -245,14 +340,17 @@ function step(state::NeedRefresh)::Union{HasNewToken, NoAuthentication} assert_dict_keys(body, "expires_in"; msg=msg) assert_dict_keys(body, "expires", "expires_at"; msg=msg) end + @info("Successfully refreshed token") return HasNewToken(state.server, body) catch err @debug "invalid body received while refreshing token" exception=(err, catch_backtrace()) end - return NoAuthentication(state.server) + @info "Did not refresh token, could not json parse ", response + return NoAuthentication(state.server, state.auth_suffix) else + @info "Did not refresh token, got non 200 response ", response @debug "request for refreshing token failed" response - return NoAuthentication(state.server) + return NoAuthentication(state.server, state.auth_suffix) end end @@ -309,15 +407,28 @@ ClaimToken immediately, or to Failure if there was an unexpected failure. """ struct RequestLogin <: State server::String + auth_suffix::String challenge::String - response::String + response::Union{String, Dict{String, Any}} + device_token_endpoint::String + device_token_refresh_url::String end -Base.show(io::IO, s::RequestLogin) = print(io, "RequestLogin($(s.server), , $(s.response))") +Base.show(io::IO, s::RequestLogin) = print(io, "RequestLogin($(s.server), $(s.auth_suffix), , $(s.response), $(s.device_token_endpoint), $(s.device_token_refresh_url))") function step(state::RequestLogin)::Union{ClaimToken, Failure} - success = open_browser(string(state.server, "/response?", state.response)) - if success - return ClaimToken(state.server, state.challenge, state.response) + is_device = !isempty(state.device_token_endpoint) + url = if is_device + string(state.response["verification_uri_complete"]) + else + "$(state.server)/$(state.auth_suffix)/response?$(state.response)" + end + + success = open_browser(url) + if success && is_device + # In case of device tokens, timeout for challenge is received in the initial request. + return ClaimToken(state.server, state.auth_suffix, state.challenge, state.response, Inf, time(), state.response["expires_in"], 2, 0, 10, state.device_token_endpoint, state.device_token_refresh_url) + elseif success + return ClaimToken(state.server, state.auth_suffix, state.challenge, state.response, state.device_token_endpoint, state.device_token_refresh_url) else # this can only happen for the browser hook return GenericError("Failed to execute open_browser hook.") end @@ -330,19 +441,22 @@ token, or to Failure if the polling times out, or there is an unexpected error. """ struct ClaimToken <: State server::String - challenge::String - response::String + auth_suffix::String + challenge::Union{Nothing, String} + response::Union{String, Dict{String, Any}} expiry::Float64 start_time::Float64 timeout::Float64 poll_interval::Float64 failures::Int max_failures::Int + device_token_endpoint::String + device_token_refresh_url::String end -Base.show(io::IO, s::ClaimToken) = print(io, "ClaimToken($(s.server), , $(s.response), $(s.expiry), $(s.start_time), $(s.timeout), $(s.poll_interval), $(s.failures), $(s.max_failures))") +Base.show(io::IO, s::ClaimToken) = print(io, "ClaimToken($(s.server), $(s.auth_suffix), , $(s.response), $(s.expiry), $(s.start_time), $(s.timeout), $(s.poll_interval), $(s.failures), $(s.max_failures), $(s.device_token_endpoint), $(s.device_token_refresh_url))") -ClaimToken(server, challenge, response, expiry = Inf, failures = 0) = - ClaimToken(server, challenge, response, expiry, time(), 180, 2, failures, 10) +ClaimToken(server, auth_suffix, challenge, response, device_token_endpoint, device_token_refresh_url, expiry = Inf, failures = 0) = + ClaimToken(server, auth_suffix, challenge, response, expiry, time(), 180, 2, failures, 10, device_token_endpoint, device_token_refresh_url) function step(state::ClaimToken)::Union{ClaimToken, HasNewToken, Failure} if time() > state.expiry || (time() - state.start_time)/1e6 > state.timeout # server-side or client-side timeout @@ -356,32 +470,53 @@ function step(state::ClaimToken)::Union{ClaimToken, HasNewToken, Failure} sleep(state.poll_interval) output = IOBuffer() - data = JSON.json(Dict( - "challenge" => state.challenge, - "response" => state.response, - )) - response = Downloads.request( - string(state.server, "/claimtoken"), - method = "POST", - input = IOBuffer(data), - output = output, - throw = false, - ) + is_device = !isempty(state.device_token_endpoint) + if is_device + output = IOBuffer() + response = Downloads.request( + state.device_token_endpoint, + method = "POST", + input = IOBuffer("client_id=$(get(ENV, "JULIA_PKG_AUTHENTICATION_DEVICE_CLIENT_ID", "device"))&scope=openid profile offline_access&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=$(state.response["device_code"])"), + output = output, + throw = false, + headers = Dict("Accept" => "application/json", "Content-Type" => "application/x-www-form-urlencoded"), + ) + else + data = JSON.json(Dict( + "challenge" => state.challenge, + "response" => state.response, + )) + response = Downloads.request( + "$(state.server)/$(state.auth_suffix)/claimtoken", + method = "POST", + input = IOBuffer(data), + output = output, + throw = false, + ) + end - if response isa Downloads.Response && response.status == 200 + if response isa Downloads.Response && response.status == 200 && !is_device body = try JSON.parse(String(take!(output))) catch err - return ClaimToken(state.server, state.challenge, state.response, state.expiry, state.start_time, state.timeout, state.poll_interval, state.failures + 1, state.max_failures) + return ClaimToken(state.server, state.auth_suffix, state.challenge, state.response, state.expiry, state.start_time, state.timeout, state.poll_interval, state.failures + 1, state.max_failures, state.device_token_endpoint, state.device_token_refresh_url) end if haskey(body, "token") return HasNewToken(state.server, body["token"]) elseif haskey(body, "expiry") # time at which the response/challenge pair will expire on the server - return ClaimToken(state.server, state.challenge, state.response, body["expiry"], state.start_time, state.timeout, state.poll_interval, state.failures, state.max_failures) + return ClaimToken(state.server, state.auth_suffix, state.challenge, state.response, body["expiry"], state.start_time, state.timeout, state.poll_interval, state.failures, state.max_failures, state.device_token_endpoint, state.device_token_refresh_url) else - return ClaimToken(state.server, state.challenge, state.response, state.expiry, state.start_time, state.timeout, state.poll_interval, state.failures + 1, state.max_failures) + return ClaimToken(state.server, state.auth_suffix, state.challenge, state.response, state.expiry, state.start_time, state.timeout, state.poll_interval, state.failures + 1, state.max_failures, state.device_token_endpoint, state.device_token_refresh_url) end + elseif response isa Downloads.Response && response.status == 200 + body = JSON.parse(String(take!(output))) + body["expires"] = body["expires_in"] + Int(floor(time())) + body["expires_at"] = body["expires"] + body["refresh_url"] = state.device_token_refresh_url + return HasNewToken(state.server, body) + elseif response isa Downloads.Response && response.status in [401, 400] && is_device + return ClaimToken(state.server, state.auth_suffix, state.challenge, state.response, state.expiry, state.start_time, state.timeout, state.poll_interval, state.failures + 1, state.max_failures, state.device_token_endpoint, state.device_token_refresh_url) else return HttpError(response) end @@ -519,7 +654,7 @@ function open_browser(url::AbstractString) OPEN_BROWSER_HOOK[](url) return true catch err - @debug "error executing browser hook" exception=(err, catch_backtrace()) + @info "error executing browser hook" exception=(err, catch_backtrace()) return false end elseif Sys.iswindows() || detectwsl() diff --git a/test/authserver.jl b/test/authserver.jl index e447daf..10c6ee1 100644 --- a/test/authserver.jl +++ b/test/authserver.jl @@ -1,12 +1,15 @@ using HTTP, Random, JSON -import TOML +import Pkg: TOML const EXPIRY = 30 const CHALLENGE_EXPIRY = 10 const PORT = 8888 +const LEGACY_MODE = 1 +const DEVICE_FLOW_MODE = 2 const ID_TOKEN = Random.randstring(100) const TOKEN = Ref(Dict()) +const MODE = Ref(LEGACY_MODE) challenge_response_map = Dict() challenge_timeout = Dict() @@ -59,7 +62,7 @@ function claimtoken_handler(req) @show payload @show challenge_response_map if haskey(challenge_response_map, payload["challenge"]) && - challenge_response_map[payload["challenge"]] == payload["response"] + challenge_response_map[payload["challenge"]] == payload["response"] delete!(challenge_response_map, payload["challenge"]) delete!(response_challenge_map, payload["response"]) @@ -99,12 +102,95 @@ function check_validity(req) return HTTP.Response(200, payload == TOKEN[]) end +function set_mode_legacy(req) + MODE[] = LEGACY_MODE + return HTTP.Response(200) +end + +function set_mode_device(req) + MODE[] = DEVICE_FLOW_MODE + return HTTP.Response(200) +end + +function auth_configuration(req) + if MODE[] == LEGACY_MODE + return HTTP.Response(200) + else + return HTTP.Response( + 200, + """ { + "auth_flows": ["classic", "device"], + "device_token_refresh_url": "http://localhost:$PORT/auth/renew/token.toml/device/", + "device_authorization_endpoint": "http://localhost:$PORT/auth/device/code", + "device_token_endpoint": "http://localhost:$PORT/auth/token" + } """, + ) + end +end + +device_code_user_code_map = Dict{String, Any}() +user_code_device_code_map = Dict{String, Any}() +authenticated = Dict{String, Any}() +function auth_device_code(req) + device_code = randstring(64) + user_code = randstring(8) + device_code_user_code_map[device_code] = user_code + user_code_device_code_map[user_code] = device_code + return HTTP.Response( + 200, + """ { + "device_code": "$device_code", + "user_code": "$user_code", + "verification_uri_complete": "http://localhost:$PORT/auth/device?user_code=$user_code", + "expires_in": $CHALLENGE_EXPIRY + } """, + ) +end + +function auth_device(req) + params = HTTP.queryparams(HTTP.URIs.URI(req.target).query) + user_code = get(params, "user_code", "") + device_code = get(user_code_device_code_map, user_code, nothing) + if device_code === nothing + return HTTP.Response(400) + end + authenticated[device_code] = true + refresh_token = Random.randstring(10) + TOKEN[]["access_token"] = "device-$ID_TOKEN" + TOKEN[]["token_type"] = "bearer" + TOKEN[]["expires_in"] = EXPIRY + TOKEN[]["refresh_token"] = refresh_token + TOKEN[]["id_token"] = "device-$ID_TOKEN" + return HTTP.Response(200) +end + +function auth_token(req) + p = split(String(req.body), "&") + d = Dict{String, Any}() + for l in p + kv = split(String(l), "=") + d[String(kv[1])] = String(kv[2]) + end + device_code = get(d, "device_code", nothing) + if device_code === nothing || !get(authenticated, device_code, false) + return HTTP.Response(401) + end + return HTTP.Response(200, JSON.json(TOKEN[])) +end + router = HTTP.Router() HTTP.register!(router, "POST", "/auth/challenge", challenge_handler) HTTP.register!(router, "GET", "/auth/response", response_handler) HTTP.register!(router, "POST", "/auth/claimtoken", claimtoken_handler) HTTP.register!(router, "GET", "/auth/renew/token.toml/v2", renew_handler) HTTP.register!(router, "POST", "/auth/isvalid", check_validity) +HTTP.register!(router, "GET", "/auth/configuration", auth_configuration) +HTTP.register!(router, "POST", "/auth/device/code", auth_device_code) +HTTP.register!(router, "GET", "/auth/device", auth_device) +HTTP.register!(router, "POST", "/auth/token", auth_token) +HTTP.register!(router, "GET", "/auth/renew/token.toml/device", renew_handler) +HTTP.register!(router, "POST", "/set_mode/legacy", set_mode_legacy) +HTTP.register!(router, "POST", "/set_mode/device", set_mode_device) function run() println("starting server") diff --git a/test/tests.jl b/test/tests.jl index 7c8d4d9..a56c85d 100644 --- a/test/tests.jl +++ b/test/tests.jl @@ -61,6 +61,34 @@ PkgAuthentication.register_open_browser_hook(url -> HTTP.get(url)) @test startswith(success2.token["id_token"], "refresh-") end +@testset "auth with running server (device flow)" begin + delete_token() + HTTP.post(joinpath(test_pkg_server, "set_mode/device")) + + @info "testing inital auth" + success = PkgAuthentication.authenticate(test_pkg_server) + + @test success isa PkgAuthentication.Success + @test success.token["expires_at"] > time() + @test startswith(success.token["id_token"], "device-") + @test !occursin("id_token", sprint(show, success)) + + sleeptimer = ceil(Int, success.token["expires_at"] - time() + 1) + @info "sleep for $(sleeptimer)s (until refresh necessary)" + sleep(sleeptimer) + + @info "testing auth refresh" + success2 = PkgAuthentication.authenticate(test_pkg_server) + @test success2 isa PkgAuthentication.Success + @test !occursin("id_token", sprint(show, success2)) + @test success2.token["expires_at"] > time() + @test success2.token["refresh_token"] !== success.token["refresh_token"] + @test startswith(success2.token["id_token"], "refresh-") + + HTTP.post(joinpath(test_pkg_server, "set_mode/legacy")) +end + + @testset "PkgAuthentication.install" begin delete_token()