diff --git a/Project.toml b/Project.toml index bee250e..bda2e8a 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "PkgAuthentication" uuid = "4722fa14-9d28-45f9-a1e2-a38605bd88f0" authors = ["Sebastian Pfitzner", "contributors"] -version = "2.2.0" +version = "2.2.1" [deps] Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" diff --git a/docs/auth-flows.md b/docs/auth-flows.md index 77c9d70..23089d9 100644 --- a/docs/auth-flows.md +++ b/docs/auth-flows.md @@ -106,16 +106,31 @@ When device authentication is not supported by the server the response body MAY } ``` +If the `auth_flows` property is present, it MUST be an array of strings. +If it is missing, it is assumed to have the value `["classic"]`. + In this case, PkgAuthentication will execute the Classic Authentication Flow. -When device authentication _is_ supported by the server, the response body MUST contain: +When device authentication _is_ supported by the server, the response body MUST contain the `auth_flows` property, and the array MUST contain the value `device`. +Additionally, the response body MUST contain the following properties: + +- `device_authorization_endpoint`: URL to be used to initiate the device authentication flow. +- `device_token_endpoint`: URL to be used to exchange the device code for a token. +- `device_token_refresh_url`: URL that can be used to refresh the token. + +Furthermore, the response body MAY contain the following properties: + +- `device_token_scope`: Scope to be used when requesting a token. If missing, the scope will be omitted from the device token request. + +An example of a possible valid response body: ```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" + "device_token_endpoint": "https://auth.juliahub.com/auth/token", + "device_token_refresh_url": "https://juliahub.com/auth/renew/token.toml/device/", + "device_token_scope": "openid email profile offline_access" } ``` diff --git a/src/PkgAuthentication.jl b/src/PkgAuthentication.jl index 24d0f66..811bcd0 100644 --- a/src/PkgAuthentication.jl +++ b/src/PkgAuthentication.jl @@ -248,8 +248,9 @@ end function step(state::NoAuthentication)::Union{RequestLogin, Failure} auth_config = get_auth_configuration(state) + scope = get(auth_config, "device_token_scope", nothing) success, challenge, body_or_response = if "device" in get(auth_config, "auth_flows", []) - fetch_device_code(state, auth_config["device_authorization_endpoint"]) + fetch_device_code(state, auth_config["device_authorization_endpoint"], scope) else initiate_browser_challenge(state) end @@ -267,14 +268,14 @@ function step(state::NoAuthentication)::Union{RequestLogin, Failure} end end -function fetch_device_code(state::NoAuthentication, device_endpoint::AbstractString) +function fetch_device_code(state::NoAuthentication, device_endpoint::AbstractString, device_scope::Union{AbstractString, Nothing}) output = IOBuffer() response = Downloads.request( device_endpoint, method = "POST", input = device_token_request_body( client_id = device_client_id(), - scope = "openid profile offline_access", + scope = device_scope, ), output = output, throw = false, diff --git a/test/authserver.jl b/test/authserver.jl index 10c6ee1..07c01da 100644 --- a/test/authserver.jl +++ b/test/authserver.jl @@ -4,12 +4,11 @@ import Pkg: TOML const EXPIRY = 30 const CHALLENGE_EXPIRY = 10 const PORT = 8888 -const LEGACY_MODE = 1 -const DEVICE_FLOW_MODE = 2 +@enum AuthFlowMode CLASSIC_MODE DEVICE_FLOW_MODE DEVICE_FLOW_NO_SCOPE_MODE const ID_TOKEN = Random.randstring(100) const TOKEN = Ref(Dict()) -const MODE = Ref(LEGACY_MODE) +const MODE = Ref(CLASSIC_MODE) challenge_response_map = Dict() challenge_timeout = Dict() @@ -102,29 +101,41 @@ 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 +function set_mode(req) + global MODE + # We want to grab the last path element of the '/set_mode/{mode}' URI + mode = last(split(HTTP.URIs.URI(req.target).path, '/')) + if mode == "classic" + MODE[] = CLASSIC_MODE + elseif mode == "device" + MODE[] = DEVICE_FLOW_MODE + elseif mode == "device-no-scope" + MODE[] = DEVICE_FLOW_NO_SCOPE_MODE + else + return HTTP.Response(400, "Invalid Mode $(mode)") + end return HTTP.Response(200) end function auth_configuration(req) - if MODE[] == LEGACY_MODE - return HTTP.Response(200) + global MODE + if MODE[] == CLASSIC_MODE + # classic mode could also return `auth_flows = ["classic"]`, but we choose to test + # the legacy case where the configuration is not implemented at all (which also + # implies the classic mode). + return HTTP.Response(501, "Not Implemented") 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" - } """, + body = Dict( + "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", ) + # device_token_scope omitted in DEVICE_FLOW_NO_SCOPE_MODE + if MODE[] == DEVICE_FLOW_MODE + body["device_token_scope"] = "openid" + end + return HTTP.Response(200, JSON.json(body)) end end @@ -189,8 +200,16 @@ 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) +# We run tests on Julia 1.3-1.5, so we need to also support HTTP 0.9 server. +# Unfortunately, HTTP 0.9 does not support variables in route paths, so +# we can't do +# +# HTTP.register!(router, "POST", "/set_mode/{mode}", set_mode) +# +# So we hack around this. +for mode in ["classic", "device", "device-no-scope"] + HTTP.register!(router, "POST", "/set_mode/$(mode)", set_mode) +end function run() println("starting server") diff --git a/test/tests.jl b/test/tests.jl index a56c85d..55a687a 100644 --- a/test/tests.jl +++ b/test/tests.jl @@ -19,6 +19,13 @@ function delete_token() rm(servers_dir; force = true, recursive = true) end +# Helper function to do the GET against /auth/configuration +# Note: having a / at the end of the first argument of NoAuthentication +# will break the HTTP call. +get_auth_configuration() = PkgAuthentication.get_auth_configuration( + PkgAuthentication.NoAuthentication(rstrip(test_pkg_server, '/'), "auth") +) + @testset "auth without server" begin delete_token() success = PkgAuthentication.authenticate(test_pkg_server) @@ -30,7 +37,7 @@ authserver_file = joinpath(@__DIR__, "authserver.jl") cmd = `$(Base.julia_cmd()) $(authserver_file)` env2 = copy(ENV) env2["JULIA_PROJECT"] = Base.active_project() -p = run(pipeline(setenv(cmd, env2), stdout="server_out.log", stderr="server_err.log"), wait=false) +p = run(pipeline(setenv(cmd, env2), stdout=stdout, stderr=stdout), wait=false) atexit(() -> kill(p)) sleep(10) @@ -65,6 +72,42 @@ end delete_token() HTTP.post(joinpath(test_pkg_server, "set_mode/device")) + # Double check that the test server is responding with the correct + # configuration information. + config = get_auth_configuration() + @test haskey(config, "device_token_scope") + @test config["device_token_scope"] == "openid" + + @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/classic")) +end + +@testset "auth with running server (device flow; no scope)" begin + delete_token() + HTTP.post(joinpath(test_pkg_server, "set_mode/device-no-scope")) + + config = get_auth_configuration() + @test !haskey(config, "device_token_scope") + @info "testing inital auth" success = PkgAuthentication.authenticate(test_pkg_server) @@ -85,7 +128,7 @@ end @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")) + HTTP.post(joinpath(test_pkg_server, "set_mode/classic")) end