Skip to content

Commit 3a4702a

Browse files
authored
fix: fetch token scope from discovery endpoint (#46)
1 parent 3e66ca1 commit 3a4702a

File tree

5 files changed

+109
-31
lines changed

5 files changed

+109
-31
lines changed

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "PkgAuthentication"
22
uuid = "4722fa14-9d28-45f9-a1e2-a38605bd88f0"
33
authors = ["Sebastian Pfitzner", "contributors"]
4-
version = "2.2.0"
4+
version = "2.2.1"
55

66
[deps]
77
Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6"

docs/auth-flows.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,31 @@ When device authentication is not supported by the server the response body MAY
106106
}
107107
```
108108

109+
If the `auth_flows` property is present, it MUST be an array of strings.
110+
If it is missing, it is assumed to have the value `["classic"]`.
111+
109112
In this case, PkgAuthentication will execute the Classic Authentication Flow.
110113

111-
When device authentication _is_ supported by the server, the response body MUST contain:
114+
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`.
115+
Additionally, the response body MUST contain the following properties:
116+
117+
- `device_authorization_endpoint`: URL to be used to initiate the device authentication flow.
118+
- `device_token_endpoint`: URL to be used to exchange the device code for a token.
119+
- `device_token_refresh_url`: URL that can be used to refresh the token.
120+
121+
Furthermore, the response body MAY contain the following properties:
122+
123+
- `device_token_scope`: Scope to be used when requesting a token. If missing, the scope will be omitted from the device token request.
124+
125+
An example of a possible valid response body:
112126

113127
```json
114128
{
115129
"auth_flows": ["classic", "device"],
116-
"device_token_refresh_url": "https://juliahub.com/auth/renew/token.toml/device/",
117130
"device_authorization_endpoint": "https://auth.juliahub.com/auth/device/code",
118-
"device_token_endpoint": "https://auth.juliahub.com/auth/token"
131+
"device_token_endpoint": "https://auth.juliahub.com/auth/token",
132+
"device_token_refresh_url": "https://juliahub.com/auth/renew/token.toml/device/",
133+
"device_token_scope": "openid email profile offline_access"
119134
}
120135
```
121136

src/PkgAuthentication.jl

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,9 @@ end
248248

249249
function step(state::NoAuthentication)::Union{RequestLogin, Failure}
250250
auth_config = get_auth_configuration(state)
251+
scope = get(auth_config, "device_token_scope", nothing)
251252
success, challenge, body_or_response = if "device" in get(auth_config, "auth_flows", [])
252-
fetch_device_code(state, auth_config["device_authorization_endpoint"])
253+
fetch_device_code(state, auth_config["device_authorization_endpoint"], scope)
253254
else
254255
initiate_browser_challenge(state)
255256
end
@@ -267,14 +268,14 @@ function step(state::NoAuthentication)::Union{RequestLogin, Failure}
267268
end
268269
end
269270

270-
function fetch_device_code(state::NoAuthentication, device_endpoint::AbstractString)
271+
function fetch_device_code(state::NoAuthentication, device_endpoint::AbstractString, device_scope::Union{AbstractString, Nothing})
271272
output = IOBuffer()
272273
response = Downloads.request(
273274
device_endpoint,
274275
method = "POST",
275276
input = device_token_request_body(
276277
client_id = device_client_id(),
277-
scope = "openid profile offline_access",
278+
scope = device_scope,
278279
),
279280
output = output,
280281
throw = false,

test/authserver.jl

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@ import Pkg: TOML
44
const EXPIRY = 30
55
const CHALLENGE_EXPIRY = 10
66
const PORT = 8888
7-
const LEGACY_MODE = 1
8-
const DEVICE_FLOW_MODE = 2
7+
@enum AuthFlowMode CLASSIC_MODE DEVICE_FLOW_MODE DEVICE_FLOW_NO_SCOPE_MODE
98

109
const ID_TOKEN = Random.randstring(100)
1110
const TOKEN = Ref(Dict())
12-
const MODE = Ref(LEGACY_MODE)
11+
const MODE = Ref(CLASSIC_MODE)
1312

1413
challenge_response_map = Dict()
1514
challenge_timeout = Dict()
@@ -102,29 +101,41 @@ function check_validity(req)
102101
return HTTP.Response(200, payload == TOKEN[])
103102
end
104103

105-
function set_mode_legacy(req)
106-
MODE[] = LEGACY_MODE
107-
return HTTP.Response(200)
108-
end
109-
110-
function set_mode_device(req)
111-
MODE[] = DEVICE_FLOW_MODE
104+
function set_mode(req)
105+
global MODE
106+
# We want to grab the last path element of the '/set_mode/{mode}' URI
107+
mode = last(split(HTTP.URIs.URI(req.target).path, '/'))
108+
if mode == "classic"
109+
MODE[] = CLASSIC_MODE
110+
elseif mode == "device"
111+
MODE[] = DEVICE_FLOW_MODE
112+
elseif mode == "device-no-scope"
113+
MODE[] = DEVICE_FLOW_NO_SCOPE_MODE
114+
else
115+
return HTTP.Response(400, "Invalid Mode $(mode)")
116+
end
112117
return HTTP.Response(200)
113118
end
114119

115120
function auth_configuration(req)
116-
if MODE[] == LEGACY_MODE
117-
return HTTP.Response(200)
121+
global MODE
122+
if MODE[] == CLASSIC_MODE
123+
# classic mode could also return `auth_flows = ["classic"]`, but we choose to test
124+
# the legacy case where the configuration is not implemented at all (which also
125+
# implies the classic mode).
126+
return HTTP.Response(501, "Not Implemented")
118127
else
119-
return HTTP.Response(
120-
200,
121-
""" {
122-
"auth_flows": ["classic", "device"],
123-
"device_token_refresh_url": "http://localhost:$PORT/auth/renew/token.toml/device/",
124-
"device_authorization_endpoint": "http://localhost:$PORT/auth/device/code",
125-
"device_token_endpoint": "http://localhost:$PORT/auth/token"
126-
} """,
128+
body = Dict(
129+
"auth_flows" => ["classic", "device"],
130+
"device_token_refresh_url" => "http://localhost:$PORT/auth/renew/token.toml/device/",
131+
"device_authorization_endpoint" => "http://localhost:$PORT/auth/device/code",
132+
"device_token_endpoint" => "http://localhost:$PORT/auth/token",
127133
)
134+
# device_token_scope omitted in DEVICE_FLOW_NO_SCOPE_MODE
135+
if MODE[] == DEVICE_FLOW_MODE
136+
body["device_token_scope"] = "openid"
137+
end
138+
return HTTP.Response(200, JSON.json(body))
128139
end
129140
end
130141

@@ -189,8 +200,16 @@ HTTP.register!(router, "POST", "/auth/device/code", auth_device_code)
189200
HTTP.register!(router, "GET", "/auth/device", auth_device)
190201
HTTP.register!(router, "POST", "/auth/token", auth_token)
191202
HTTP.register!(router, "GET", "/auth/renew/token.toml/device", renew_handler)
192-
HTTP.register!(router, "POST", "/set_mode/legacy", set_mode_legacy)
193-
HTTP.register!(router, "POST", "/set_mode/device", set_mode_device)
203+
# We run tests on Julia 1.3-1.5, so we need to also support HTTP 0.9 server.
204+
# Unfortunately, HTTP 0.9 does not support variables in route paths, so
205+
# we can't do
206+
#
207+
# HTTP.register!(router, "POST", "/set_mode/{mode}", set_mode)
208+
#
209+
# So we hack around this.
210+
for mode in ["classic", "device", "device-no-scope"]
211+
HTTP.register!(router, "POST", "/set_mode/$(mode)", set_mode)
212+
end
194213

195214
function run()
196215
println("starting server")

test/tests.jl

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ function delete_token()
1919
rm(servers_dir; force = true, recursive = true)
2020
end
2121

22+
# Helper function to do the GET against /auth/configuration
23+
# Note: having a / at the end of the first argument of NoAuthentication
24+
# will break the HTTP call.
25+
get_auth_configuration() = PkgAuthentication.get_auth_configuration(
26+
PkgAuthentication.NoAuthentication(rstrip(test_pkg_server, '/'), "auth")
27+
)
28+
2229
@testset "auth without server" begin
2330
delete_token()
2431
success = PkgAuthentication.authenticate(test_pkg_server)
@@ -30,7 +37,7 @@ authserver_file = joinpath(@__DIR__, "authserver.jl")
3037
cmd = `$(Base.julia_cmd()) $(authserver_file)`
3138
env2 = copy(ENV)
3239
env2["JULIA_PROJECT"] = Base.active_project()
33-
p = run(pipeline(setenv(cmd, env2), stdout="server_out.log", stderr="server_err.log"), wait=false)
40+
p = run(pipeline(setenv(cmd, env2), stdout=stdout, stderr=stdout), wait=false)
3441
atexit(() -> kill(p))
3542
sleep(10)
3643

@@ -65,6 +72,42 @@ end
6572
delete_token()
6673
HTTP.post(joinpath(test_pkg_server, "set_mode/device"))
6774

75+
# Double check that the test server is responding with the correct
76+
# configuration information.
77+
config = get_auth_configuration()
78+
@test haskey(config, "device_token_scope")
79+
@test config["device_token_scope"] == "openid"
80+
81+
@info "testing inital auth"
82+
success = PkgAuthentication.authenticate(test_pkg_server)
83+
84+
@test success isa PkgAuthentication.Success
85+
@test success.token["expires_at"] > time()
86+
@test startswith(success.token["id_token"], "device-")
87+
@test !occursin("id_token", sprint(show, success))
88+
89+
sleeptimer = ceil(Int, success.token["expires_at"] - time() + 1)
90+
@info "sleep for $(sleeptimer)s (until refresh necessary)"
91+
sleep(sleeptimer)
92+
93+
@info "testing auth refresh"
94+
success2 = PkgAuthentication.authenticate(test_pkg_server)
95+
@test success2 isa PkgAuthentication.Success
96+
@test !occursin("id_token", sprint(show, success2))
97+
@test success2.token["expires_at"] > time()
98+
@test success2.token["refresh_token"] !== success.token["refresh_token"]
99+
@test startswith(success2.token["id_token"], "refresh-")
100+
101+
HTTP.post(joinpath(test_pkg_server, "set_mode/classic"))
102+
end
103+
104+
@testset "auth with running server (device flow; no scope)" begin
105+
delete_token()
106+
HTTP.post(joinpath(test_pkg_server, "set_mode/device-no-scope"))
107+
108+
config = get_auth_configuration()
109+
@test !haskey(config, "device_token_scope")
110+
68111
@info "testing inital auth"
69112
success = PkgAuthentication.authenticate(test_pkg_server)
70113

@@ -85,7 +128,7 @@ end
85128
@test success2.token["refresh_token"] !== success.token["refresh_token"]
86129
@test startswith(success2.token["id_token"], "refresh-")
87130

88-
HTTP.post(joinpath(test_pkg_server, "set_mode/legacy"))
131+
HTTP.post(joinpath(test_pkg_server, "set_mode/classic"))
89132
end
90133

91134

0 commit comments

Comments
 (0)