From 7b2742cd64bbccb966c1da0b235f74451b7f89a5 Mon Sep 17 00:00:00 2001 From: nkottary Date: Mon, 28 Apr 2025 16:10:13 +0000 Subject: [PATCH 01/14] * Device flow auth * refactor, use dex well known conf, use env for client id * Add docs for device flow * Move auth_suffix under states * tests for device auth * Use custom config endpoint --- docs/state-machine.md | 76 +++++++++++++ src/PkgAuthentication.jl | 229 +++++++++++++++++++++++++++++++-------- test/authserver.jl | 94 +++++++++++++++- test/tests.jl | 28 +++++ 4 files changed, 383 insertions(+), 44 deletions(-) diff --git a/docs/state-machine.md b/docs/state-machine.md index 1672d99..50b8120 100644 --- a/docs/state-machine.md +++ b/docs/state-machine.md @@ -104,3 +104,79 @@ stateDiagram-v2 ``` > **Note** This file is automatically generated by the `bin/structure.jl` script. + +## Device flow authentication + +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). + +### Working of device flow in PkgAuthentication.jl + +We first call the dex [openid-configuration](https://dexidp.io/docs/openid-connect/) endpoint to determine whether the Pkg server supports device authentication. When device authentication is supported by the Pkg server we call the `/dex/device/code` endpoint. When the Pkg server does not support device authentication we fall back to the legacy browser authentication flow. The state machine for both flows are exactly the same (see diagram above). Only the http requests are different. The request and response for device code endpoint looks like: + +Request: + +``` +curl --request POST \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data 'client_id=device&scope=openid email profile offline_access' \ + https://juliahub.com/dex/device/code +``` + +Response: + +```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 +} +``` + +The `verfication_uri_complete` value is opened in the browser for the user so that they can continue logging in. As in the legacy browser flow which calls `/claimtoken` to poll for completion of authentication, we call `/dex/token` when device authentication is available. + +The poll request looks like: + +``` +curl --request POST \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data "client_id=device&scope=openid email profile offline_access&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=$device_code" \ + https://juliahub.com/dex/token +``` + +While the user hasn't finished authenticating, the response will be: + +``` +HTTP/1.1 401 Unauthorized + +{"error":"authorization_pending"} +``` + +Sometimes the response code might be 400. + +After the user is successfully authenticated, the response will be: + +```json +{ + "access_token": "abcdefghijklmnopqrstuvwxyz1234567890", + "token_type": "bearer", + "expires_in": 86399, + "refresh_token": "abcdefghijklmnopqrstuvwxyz1234567890", + "id_token": "abcdefghijklmnopqrstuvwxyz1234567890" +} +``` + +We generate content for the `auth.toml` file with these values. We add some extra key/values to auth.toml when device authentication is enabled: +- `client: "device"` This will help us distinguish between device authenticated auth.toml's and legacy auth.toml's. Not to be confused with `client_id` parameter that is used in the http requests. (See below) +- `expires_at: + ` This value is required to determine whether the token is expired and needs refresh. This is missing in the token response so we add it by summing the `expires_in` 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. We create this field with value `/auth/renew/token.toml/device/`. + +The mechanism to refresh the token is the same as in the legacy browser authentication flow. + +### 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..8625986 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,124 @@ 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: +# - `device_flow_supported`::Bool: Indicates whether device flow is +# enabled on the server. +# - `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. +# - `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( + joinpath(state.server, auth_suffix, "configuration"), + method = "GET", + output = output, + throw = false, + headers = ["Accept" => "application/json"], + ) + + def_resp = Dict{String, Any}( + "device_flow_supported" => false, + "refresh_url" => joinpath(state.server, auth_suffix, "renew/token.toml/v2/") + ) + + 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 def_resp + end + + if body !== nothing + @assert haskey(body, "device_flow_supported") + @assert haskey(body, "refresh_url") + @assert (body["device_flow_supported"] && haskey(body, "device_authorization_endpoint") && haskey(body, "token_endpoint")) || !body["device_flow_supported"] + return body + end + end + + return def_resp 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 auth_config["device_flow_supported"] + 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, "token_endpoint", ""), auth_config["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( + joinpath(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 +300,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 +323,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 +347,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 +414,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}} + token_endpoint::String + 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.token_endpoint), $(s.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.token_endpoint) + url = if is_device + string(state.response["verification_uri_complete"]) + else + joinpath(state.server, state.auth_suffix, string("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.token_endpoint, state.refresh_url) + elseif success + return ClaimToken(state.server, state.auth_suffix, state.challenge, state.response, state.token_endpoint, state.refresh_url) else # this can only happen for the browser hook return GenericError("Failed to execute open_browser hook.") end @@ -330,19 +448,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 + token_endpoint::String + 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.token_endpoint), $(s.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, token_endpoint, refresh_url, expiry = Inf, failures = 0) = + ClaimToken(server, auth_suffix, challenge, response, expiry, time(), 180, 2, failures, 10, token_endpoint, 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 +477,54 @@ 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.token_endpoint) + if is_device + output = IOBuffer() + response = Downloads.request( + state.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( + joinpath(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.token_endpoint, state.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.token_endpoint, state.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.token_endpoint, state.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"] + @info("Setting refresh url to ", state.refresh_url) + body["refresh_url"] = state.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.token_endpoint, state.refresh_url) else return HttpError(response) end diff --git a/test/authserver.jl b/test/authserver.jl index e447daf..08170e7 100644 --- a/test/authserver.jl +++ b/test/authserver.jl @@ -4,9 +4,12 @@ import 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,101 @@ 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, + """ { + "device_flow_supported": false, + "refresh_url": "http://localhost:$PORT/auth/renew/token.toml/v2/" + } """, + ) + else + return HTTP.Response( + 200, + """ { + "device_flow_supported": true, + "refresh_url": "http://localhost:$PORT/auth/renew/token.toml/device/", + "device_authorization_endpoint": "http://localhost:$PORT/auth/device/code", + "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(req) + 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() From b862fa7b6ca89f70b50a2471e9f83fba3e8169f7 Mon Sep 17 00:00:00 2001 From: nkottary Date: Thu, 22 May 2025 05:14:08 +0000 Subject: [PATCH 02/14] whitespace --- docs/state-machine.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/state-machine.md b/docs/state-machine.md index 50b8120..67cd201 100644 --- a/docs/state-machine.md +++ b/docs/state-machine.md @@ -172,7 +172,7 @@ After the user is successfully authenticated, the response will be: We generate content for the `auth.toml` file with these values. We add some extra key/values to auth.toml when device authentication is enabled: - `client: "device"` This will help us distinguish between device authenticated auth.toml's and legacy auth.toml's. Not to be confused with `client_id` parameter that is used in the http requests. (See below) -- `expires_at: + ` This value is required to determine whether the token is expired and needs refresh. This is missing in the token response so we add it by summing the `expires_in` in value with the current timestamp. +- `expires_at: + ` This value is required to determine whether the token is expired and needs refresh. This is missing in the token response so we add it by summing the `expires_in` 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. We create this field with value `/auth/renew/token.toml/device/`. The mechanism to refresh the token is the same as in the legacy browser authentication flow. From 7eea233b690b8ca7420f106dff76ca77697771e8 Mon Sep 17 00:00:00 2001 From: nkottary Date: Thu, 22 May 2025 07:00:43 +0000 Subject: [PATCH 03/14] update docs --- docs/auth-flows.md | 77 +++++++++++++++++++++++++++++++++++++++++++ docs/state-machine.md | 76 ------------------------------------------ 2 files changed, 77 insertions(+), 76 deletions(-) diff --git a/docs/auth-flows.md b/docs/auth-flows.md index db62793..f163563 100644 --- a/docs/auth-flows.md +++ b/docs/auth-flows.md @@ -76,6 +76,36 @@ 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.jl supports two different authentication mechanisms: +1. Classic Authentication Flow +2. Device Authentication Flow + +When initiating a fresh authentication, PkgAuthentication.jl calls `/auth/configuration` endpoint to determine whether the Pkg server supports device authentication. This endpoint MUST return a 200 response. When device authentication is not supported by the server the response body MUST contain the following JSON data : + +```json +{ + "device_flow_supported": false, + "refresh_url": "https://juliahub.com/auth/renew/token.toml/v2/" +} +``` + +In this case, PkgAuthentication.jl will execute the Classic Authentication Flow. When device authentication _is_ supported by the server, the response body MUST contain: + +```json +{ + "device_flow_supported": true, + "refresh_url": "https://juliahub.com/auth/renew/token.toml/device/", + "device_authorization_endpoint": "https://auth.juliahub.com/auth/device/code", + "token_endpoint": "https://auth.juliahub.com/auth/token" +} +``` + +In this case, PkgAuthentication.jl 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 +171,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 flow authentication + +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 `token_endpoint` with 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 `token_endpoint` response status will be `401` or `400`. + + When the user approves the authorization request, the `token_endpoint` response status will 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 to auth.toml to keep the content consistent with Classic Authentication Flow: + - `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 `refresh_url` from the `device_authorization_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/docs/state-machine.md b/docs/state-machine.md index 67cd201..1672d99 100644 --- a/docs/state-machine.md +++ b/docs/state-machine.md @@ -104,79 +104,3 @@ stateDiagram-v2 ``` > **Note** This file is automatically generated by the `bin/structure.jl` script. - -## Device flow authentication - -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). - -### Working of device flow in PkgAuthentication.jl - -We first call the dex [openid-configuration](https://dexidp.io/docs/openid-connect/) endpoint to determine whether the Pkg server supports device authentication. When device authentication is supported by the Pkg server we call the `/dex/device/code` endpoint. When the Pkg server does not support device authentication we fall back to the legacy browser authentication flow. The state machine for both flows are exactly the same (see diagram above). Only the http requests are different. The request and response for device code endpoint looks like: - -Request: - -``` -curl --request POST \ - -H 'Accept: application/json' \ - -H 'Content-Type: application/x-www-form-urlencoded' \ - --data 'client_id=device&scope=openid email profile offline_access' \ - https://juliahub.com/dex/device/code -``` - -Response: - -```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 -} -``` - -The `verfication_uri_complete` value is opened in the browser for the user so that they can continue logging in. As in the legacy browser flow which calls `/claimtoken` to poll for completion of authentication, we call `/dex/token` when device authentication is available. - -The poll request looks like: - -``` -curl --request POST \ - -H 'Accept: application/json' \ - -H 'Content-Type: application/x-www-form-urlencoded' \ - --data "client_id=device&scope=openid email profile offline_access&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=$device_code" \ - https://juliahub.com/dex/token -``` - -While the user hasn't finished authenticating, the response will be: - -``` -HTTP/1.1 401 Unauthorized - -{"error":"authorization_pending"} -``` - -Sometimes the response code might be 400. - -After the user is successfully authenticated, the response will be: - -```json -{ - "access_token": "abcdefghijklmnopqrstuvwxyz1234567890", - "token_type": "bearer", - "expires_in": 86399, - "refresh_token": "abcdefghijklmnopqrstuvwxyz1234567890", - "id_token": "abcdefghijklmnopqrstuvwxyz1234567890" -} -``` - -We generate content for the `auth.toml` file with these values. We add some extra key/values to auth.toml when device authentication is enabled: -- `client: "device"` This will help us distinguish between device authenticated auth.toml's and legacy auth.toml's. Not to be confused with `client_id` parameter that is used in the http requests. (See below) -- `expires_at: + ` This value is required to determine whether the token is expired and needs refresh. This is missing in the token response so we add it by summing the `expires_in` 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. We create this field with value `/auth/renew/token.toml/device/`. - -The mechanism to refresh the token is the same as in the legacy browser authentication flow. - -### 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"`. From 051ce00d2e2cd75cb87d41613892f7f940457ffb Mon Sep 17 00:00:00 2001 From: nkottary Date: Thu, 22 May 2025 07:07:45 +0000 Subject: [PATCH 04/14] styling --- docs/auth-flows.md | 54 +++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/auth-flows.md b/docs/auth-flows.md index f163563..01616f6 100644 --- a/docs/auth-flows.md +++ b/docs/auth-flows.md @@ -172,7 +172,7 @@ 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 flow authentication +### 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). @@ -180,40 +180,40 @@ 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: + 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 - } - ``` + ```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 `token_endpoint` with 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. +3. The client should now poll for completion of the authorization request. It can do so by making a `POST` request to the `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 `token_endpoint` response status will be `401` or `400`. + While the user hasn't finished responding to the authorization request or has denied the authorization request, the `token_endpoint` response status must be `401` or `400`. - When the user approves the authorization request, the `token_endpoint` response status will be 200 with a JSON body containing the `access_token`, `id_token`, `refresh_token` and `expires_in`. Example: + When the user approves the authorization request, the `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" - } - ``` + ```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 to auth.toml to keep the content consistent with Classic Authentication Flow: - - `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 `refresh_url` from the `device_authorization_endpoint` response. +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 `refresh_url` from the `device_authorization_endpoint` response. #### Client ID for device authentication flow From 52bd795a27794f20f3a0603a272f3d4820e5b52a Mon Sep 17 00:00:00 2001 From: nkottary Date: Fri, 23 May 2025 07:56:02 +0000 Subject: [PATCH 05/14] log --- src/PkgAuthentication.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PkgAuthentication.jl b/src/PkgAuthentication.jl index 8625986..518948d 100644 --- a/src/PkgAuthentication.jl +++ b/src/PkgAuthentication.jl @@ -662,7 +662,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() From 5f494b408d3830f95217c366b7bd01c1001503a7 Mon Sep 17 00:00:00 2001 From: nkottary Date: Fri, 23 May 2025 08:46:17 +0000 Subject: [PATCH 06/14] Fix for julia 1.3 --- test/authserver.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/authserver.jl b/test/authserver.jl index 08170e7..7fecd16 100644 --- a/test/authserver.jl +++ b/test/authserver.jl @@ -1,5 +1,5 @@ using HTTP, Random, JSON -import TOML +import Pkg: TOML const EXPIRY = 30 const CHALLENGE_EXPIRY = 10 @@ -154,7 +154,7 @@ function auth_device_code(req) end function auth_device(req) - params = HTTP.queryparams(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 From a588dc0eb256503f4d0b2556a26d612025eb96dc Mon Sep 17 00:00:00 2001 From: "Nishanth H. Kottary" Date: Fri, 23 May 2025 14:57:30 +0530 Subject: [PATCH 07/14] Update docs/auth-flows.md Co-authored-by: Morten Piibeleht --- docs/auth-flows.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/auth-flows.md b/docs/auth-flows.md index 01616f6..728070b 100644 --- a/docs/auth-flows.md +++ b/docs/auth-flows.md @@ -213,7 +213,7 @@ The flow goes through the following steps: 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 `refresh_url` from the `device_authorization_endpoint` response. + - `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 `refresh_url` from the package server authentication configuration endpoint response. #### Client ID for device authentication flow From 2505a46254aab8f58c244071e62cb5f45d7d2ab9 Mon Sep 17 00:00:00 2001 From: nkottary Date: Fri, 23 May 2025 09:29:13 +0000 Subject: [PATCH 08/14] Windows fix --- src/PkgAuthentication.jl | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/PkgAuthentication.jl b/src/PkgAuthentication.jl index 518948d..fe525a7 100644 --- a/src/PkgAuthentication.jl +++ b/src/PkgAuthentication.jl @@ -201,7 +201,7 @@ function get_auth_configuration(state::NoAuthentication) output = IOBuffer() auth_suffix = isempty(state.auth_suffix) ? "auth" : state.auth_suffix response = Downloads.request( - joinpath(state.server, auth_suffix, "configuration"), + "$(state.server)/$(auth_suffix)/configuration", method = "GET", output = output, throw = false, @@ -210,7 +210,7 @@ function get_auth_configuration(state::NoAuthentication) def_resp = Dict{String, Any}( "device_flow_supported" => false, - "refresh_url" => joinpath(state.server, auth_suffix, "renew/token.toml/v2/") + "refresh_url" => "$(state.server)/$(auth_suffix)/renew/token.toml/v2/" ) if response isa Downloads.Response && response.status == 200 @@ -279,7 +279,7 @@ function initiate_browser_challenge(state::NoAuthentication) output = IOBuffer() challenge = Random.randstring(32) response = Downloads.request( - joinpath(state.server, state.auth_suffix, "challenge"), + "$(state.server)/$(state.auth_suffix)/challenge", method = "POST", input = IOBuffer(challenge), output = output, @@ -427,7 +427,7 @@ function step(state::RequestLogin)::Union{ClaimToken, Failure} url = if is_device string(state.response["verification_uri_complete"]) else - joinpath(state.server, state.auth_suffix, string("response?", state.response)) + "$(state.server)/$(state.auth_suffix)/response?$(state.response)" end success = open_browser(url) @@ -494,7 +494,7 @@ function step(state::ClaimToken)::Union{ClaimToken, HasNewToken, Failure} "response" => state.response, )) response = Downloads.request( - joinpath(state.server, state.auth_suffix, "claimtoken"), + "$(state.server)/$(state.auth_suffix)/claimtoken", method = "POST", input = IOBuffer(data), output = output, From 3e2aa5d05ff226615bb070bb39fa08f8bb6f407f Mon Sep 17 00:00:00 2001 From: "Nishanth H. Kottary" Date: Fri, 23 May 2025 15:00:05 +0530 Subject: [PATCH 09/14] Update docs/auth-flows.md Co-authored-by: Morten Piibeleht --- docs/auth-flows.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/auth-flows.md b/docs/auth-flows.md index 728070b..9568406 100644 --- a/docs/auth-flows.md +++ b/docs/auth-flows.md @@ -78,7 +78,8 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S ### Authentication mechanisms -PkgAuthentication.jl supports two different authentication mechanisms: +PkgAuthentication supports two different authentication mechanisms: + 1. Classic Authentication Flow 2. Device Authentication Flow From 55696f6f31b2e4a06650e2e48016c0e84fa95670 Mon Sep 17 00:00:00 2001 From: "Nishanth H. Kottary" Date: Fri, 23 May 2025 15:01:01 +0530 Subject: [PATCH 10/14] Update docs/auth-flows.md Co-authored-by: Morten Piibeleht --- docs/auth-flows.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/auth-flows.md b/docs/auth-flows.md index 9568406..d62c1a7 100644 --- a/docs/auth-flows.md +++ b/docs/auth-flows.md @@ -83,7 +83,20 @@ PkgAuthentication supports two different authentication mechanisms: 1. Classic Authentication Flow 2. Device Authentication Flow -When initiating a fresh authentication, PkgAuthentication.jl calls `/auth/configuration` endpoint to determine whether the Pkg server supports device authentication. This endpoint MUST return a 200 response. When device authentication is not supported by the server the response body MUST contain the following JSON data : +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. ```json { From 9e69541aff45e1fa22184245a9a4e1f7fb511f0f Mon Sep 17 00:00:00 2001 From: "Nishanth H. Kottary" Date: Fri, 23 May 2025 15:05:37 +0530 Subject: [PATCH 11/14] Update docs/auth-flows.md Co-authored-by: Morten Piibeleht --- docs/auth-flows.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/auth-flows.md b/docs/auth-flows.md index d62c1a7..883642d 100644 --- a/docs/auth-flows.md +++ b/docs/auth-flows.md @@ -105,7 +105,9 @@ If the response is invalid (non-`200` code or an invalid JSON object), PkgAuthen } ``` -In this case, PkgAuthentication.jl will execute the Classic Authentication Flow. When device authentication _is_ supported by the server, the response body MUST contain: +In this case, PkgAuthentication will execute the Classic Authentication Flow. + +When device authentication _is_ supported by the server, the response body MUST contain: ```json { From 72139aea0c03b1718e11a7407d05c034b47f3281 Mon Sep 17 00:00:00 2001 From: "Nishanth H. Kottary" Date: Fri, 23 May 2025 15:05:51 +0530 Subject: [PATCH 12/14] Update docs/auth-flows.md Co-authored-by: Morten Piibeleht --- docs/auth-flows.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/auth-flows.md b/docs/auth-flows.md index 883642d..8c3a93d 100644 --- a/docs/auth-flows.md +++ b/docs/auth-flows.md @@ -118,7 +118,7 @@ When device authentication _is_ supported by the server, the response body MUST } ``` -In this case, PkgAuthentication.jl will execute the Device Authentication Flow. +In this case, PkgAuthentication will execute the Device Authentication Flow. Note: URLs in the examples are only representative. Actual URLs may differ. From 0dd85e71e03e66ed3e9df4281c03956b74d8f4e7 Mon Sep 17 00:00:00 2001 From: nkottary Date: Fri, 23 May 2025 12:34:04 +0000 Subject: [PATCH 13/14] remove refresh_url from config response when device flow is not supported --- docs/auth-flows.md | 5 ++--- src/PkgAuthentication.jl | 7 ++----- test/authserver.jl | 3 +-- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/docs/auth-flows.md b/docs/auth-flows.md index 8c3a93d..9522af6 100644 --- a/docs/auth-flows.md +++ b/docs/auth-flows.md @@ -96,12 +96,11 @@ 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. +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. ```json { - "device_flow_supported": false, - "refresh_url": "https://juliahub.com/auth/renew/token.toml/v2/" + "device_flow_supported": false } ``` diff --git a/src/PkgAuthentication.jl b/src/PkgAuthentication.jl index fe525a7..e99ee20 100644 --- a/src/PkgAuthentication.jl +++ b/src/PkgAuthentication.jl @@ -210,7 +210,6 @@ function get_auth_configuration(state::NoAuthentication) def_resp = Dict{String, Any}( "device_flow_supported" => false, - "refresh_url" => "$(state.server)/$(auth_suffix)/renew/token.toml/v2/" ) if response isa Downloads.Response && response.status == 200 @@ -225,8 +224,7 @@ function get_auth_configuration(state::NoAuthentication) if body !== nothing @assert haskey(body, "device_flow_supported") - @assert haskey(body, "refresh_url") - @assert (body["device_flow_supported"] && haskey(body, "device_authorization_endpoint") && haskey(body, "token_endpoint")) || !body["device_flow_supported"] + @assert (body["device_flow_supported"] && haskey(body, "device_authorization_endpoint") && haskey(body, "token_endpoint") && haskey(body, "refresh_url")) || !body["device_flow_supported"] return body end end @@ -242,7 +240,7 @@ function step(state::NoAuthentication)::Union{RequestLogin, Failure} initiate_browser_challenge(state) end if success - return RequestLogin(state.server, state.auth_suffix, challenge, body_or_response, get(auth_config, "token_endpoint", ""), auth_config["refresh_url"]) + return RequestLogin(state.server, state.auth_suffix, challenge, body_or_response, get(auth_config, "token_endpoint", ""), get(auth_config, "refresh_url", "")) else return HttpError(body_or_response) end @@ -520,7 +518,6 @@ function step(state::ClaimToken)::Union{ClaimToken, HasNewToken, Failure} body = JSON.parse(String(take!(output))) body["expires"] = body["expires_in"] + Int(floor(time())) body["expires_at"] = body["expires"] - @info("Setting refresh url to ", state.refresh_url) body["refresh_url"] = state.refresh_url return HasNewToken(state.server, body) elseif response isa Downloads.Response && response.status in [401, 400] && is_device diff --git a/test/authserver.jl b/test/authserver.jl index 7fecd16..c7eb0c3 100644 --- a/test/authserver.jl +++ b/test/authserver.jl @@ -117,8 +117,7 @@ function auth_configuration(req) return HTTP.Response( 200, """ { - "device_flow_supported": false, - "refresh_url": "http://localhost:$PORT/auth/renew/token.toml/v2/" + "device_flow_supported": false } """, ) else From 4974a184191ad878ad43d176f87a09eede603af7 Mon Sep 17 00:00:00 2001 From: nkottary Date: Fri, 23 May 2025 14:59:17 +0000 Subject: [PATCH 14/14] namespace device config response; use list of auth_flows --- docs/auth-flows.md | 18 ++++++------ src/PkgAuthentication.jl | 59 ++++++++++++++++++---------------------- test/authserver.jl | 13 +++------ 3 files changed, 41 insertions(+), 49 deletions(-) diff --git a/docs/auth-flows.md b/docs/auth-flows.md index 9522af6..77c9d70 100644 --- a/docs/auth-flows.md +++ b/docs/auth-flows.md @@ -98,9 +98,11 @@ For a valid implementation of the configuration endpoint, the package server: 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 { - "device_flow_supported": false + "auth_flows": ["classic"] } ``` @@ -110,10 +112,10 @@ When device authentication _is_ supported by the server, the response body MUST ```json { - "device_flow_supported": true, - "refresh_url": "https://juliahub.com/auth/renew/token.toml/device/", + "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", - "token_endpoint": "https://auth.juliahub.com/auth/token" + "device_token_endpoint": "https://auth.juliahub.com/auth/token" } ``` @@ -210,11 +212,11 @@ The flow goes through the following steps: 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 `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. +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 `token_endpoint` response status must be `401` or `400`. + 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 `token_endpoint` response status must be 200 with a JSON body containing the `access_token`, `id_token`, `refresh_token` and `expires_in`. Example: + 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 { @@ -228,7 +230,7 @@ The flow goes through the following steps: 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 `refresh_url` from the package server authentication configuration endpoint response. + - `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 diff --git a/src/PkgAuthentication.jl b/src/PkgAuthentication.jl index e99ee20..ed41cf5 100644 --- a/src/PkgAuthentication.jl +++ b/src/PkgAuthentication.jl @@ -186,14 +186,14 @@ end # Query the /auth/configuration endpoint to get the refresh url and # device authentication endpoints. Returns a Dict with the following # fields: -# - `device_flow_supported`::Bool: Indicates whether device flow is -# enabled on the server. -# - `refresh_url`::String: The refresh URL for refreshing the auth +# - `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. -# - `token_endpoint`::String: The endpoint that should be called to +# - `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. @@ -208,10 +208,6 @@ function get_auth_configuration(state::NoAuthentication) headers = ["Accept" => "application/json"], ) - def_resp = Dict{String, Any}( - "device_flow_supported" => false, - ) - if response isa Downloads.Response && response.status == 200 body = nothing content = String(take!(output)) @@ -219,28 +215,27 @@ function get_auth_configuration(state::NoAuthentication) body = JSON.parse(content) catch ex @debug "Request for well known configuration returned: ", content - return def_resp + return Dict{String, Any}() end if body !== nothing - @assert haskey(body, "device_flow_supported") - @assert (body["device_flow_supported"] && haskey(body, "device_authorization_endpoint") && haskey(body, "token_endpoint") && haskey(body, "refresh_url")) || !body["device_flow_supported"] + @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 def_resp + return Dict{String, Any}() end function step(state::NoAuthentication)::Union{RequestLogin, Failure} auth_config = get_auth_configuration(state) - success, challenge, body_or_response = if auth_config["device_flow_supported"] + 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, "token_endpoint", ""), get(auth_config, "refresh_url", "")) + 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 @@ -415,13 +410,13 @@ struct RequestLogin <: State auth_suffix::String challenge::String response::Union{String, Dict{String, Any}} - token_endpoint::String - refresh_url::String + device_token_endpoint::String + device_token_refresh_url::String end -Base.show(io::IO, s::RequestLogin) = print(io, "RequestLogin($(s.server), $(s.auth_suffix), , $(s.response), $(s.token_endpoint), $(s.refresh_url))") +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} - is_device = !isempty(state.token_endpoint) + is_device = !isempty(state.device_token_endpoint) url = if is_device string(state.response["verification_uri_complete"]) else @@ -431,9 +426,9 @@ function step(state::RequestLogin)::Union{ClaimToken, Failure} 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.token_endpoint, state.refresh_url) + 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.token_endpoint, state.refresh_url) + 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 @@ -455,13 +450,13 @@ struct ClaimToken <: State poll_interval::Float64 failures::Int max_failures::Int - token_endpoint::String - refresh_url::String + device_token_endpoint::String + device_token_refresh_url::String end -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.token_endpoint), $(s.refresh_url))") +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, auth_suffix, challenge, response, token_endpoint, refresh_url, expiry = Inf, failures = 0) = - ClaimToken(server, auth_suffix, challenge, response, expiry, time(), 180, 2, failures, 10, token_endpoint, refresh_url) +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 @@ -475,11 +470,11 @@ function step(state::ClaimToken)::Union{ClaimToken, HasNewToken, Failure} sleep(state.poll_interval) output = IOBuffer() - is_device = !isempty(state.token_endpoint) + is_device = !isempty(state.device_token_endpoint) if is_device output = IOBuffer() response = Downloads.request( - state.token_endpoint, + 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, @@ -504,24 +499,24 @@ function step(state::ClaimToken)::Union{ClaimToken, HasNewToken, Failure} body = try JSON.parse(String(take!(output))) catch err - 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.token_endpoint, state.refresh_url) + 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.auth_suffix, state.challenge, state.response, body["expiry"], state.start_time, state.timeout, state.poll_interval, state.failures, state.max_failures, state.token_endpoint, state.refresh_url) + 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.auth_suffix, state.challenge, state.response, state.expiry, state.start_time, state.timeout, state.poll_interval, state.failures + 1, state.max_failures, state.token_endpoint, state.refresh_url) + 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.refresh_url + 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.token_endpoint, state.refresh_url) + 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 diff --git a/test/authserver.jl b/test/authserver.jl index c7eb0c3..10c6ee1 100644 --- a/test/authserver.jl +++ b/test/authserver.jl @@ -114,20 +114,15 @@ end function auth_configuration(req) if MODE[] == LEGACY_MODE - return HTTP.Response( - 200, - """ { - "device_flow_supported": false - } """, - ) + return HTTP.Response(200) else return HTTP.Response( 200, """ { - "device_flow_supported": true, - "refresh_url": "http://localhost:$PORT/auth/renew/token.toml/device/", + "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", - "token_endpoint": "http://localhost:$PORT/auth/token" + "device_token_endpoint": "http://localhost:$PORT/auth/token" } """, ) end