Skip to content

fix: fetch token scope from discovery endpoint #46

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
21 changes: 18 additions & 3 deletions docs/auth-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
```

Expand Down
7 changes: 4 additions & 3 deletions src/PkgAuthentication.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
63 changes: 41 additions & 22 deletions test/authserver.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
47 changes: 45 additions & 2 deletions test/tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand All @@ -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


Expand Down
Loading