Skip to content

Commit c109496

Browse files
authored
Merge pull request #8 from PlaceOS/PPT-2536-refresh-scope
PPT-2536: preserve OAuth scope across token refresh (fixes dev 403)
2 parents 5ee55ae + 4a34b22 commit c109496

2 files changed

Lines changed: 150 additions & 4 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
require "../helper"
2+
require "jwt"
3+
4+
module PlaceOS::Auth
5+
# Reproduction for the dev-server 403 (PPT-2536): a *refreshed* access token
6+
# was losing its `scope` entirely. authly's refresh grant derives scope from
7+
# the request (absent on a standard refresh) or the auth code (absent on a
8+
# refresh) and otherwise returns "" — so `ClaimsProvider` emitted
9+
# `scope: [] `. Downstream rest-api's `can_read` needs the `public` scope
10+
# present, so every API call with a refreshed token 403'd
11+
# (e.g. GET /api/engine/v2/oauth_apps).
12+
#
13+
# The fix must carry the originally-granted scope across refresh, matching
14+
# the Ruby Doorkeeper behaviour.
15+
describe OAuth, tags: "refresh-scope" do
16+
decode = ->(token : String) {
17+
payload, _ = JWT.decode(token, ::Authly.config.public_key.as(String), JWT::Algorithm::RS256)
18+
payload
19+
}
20+
21+
scopes_of = ->(claims : JSON::Any) {
22+
# UserJWT emits scope as an array of scope strings.
23+
claims["scope"].as_a.map(&.as_s)
24+
}
25+
26+
login = -> {
27+
authority = ::PlaceOS::Model::Authority.find_by_domain("localhost").not_nil!
28+
user = ::PlaceOS::Model::Generator.user(authority).tap do |u|
29+
u.name = "Refresh Scope User"
30+
u.support = true
31+
u.sys_admin = true
32+
u.save!
33+
end
34+
password = "bcrypt-please-#{Random.rand(99999)}"
35+
user.password = password
36+
user.save!
37+
38+
app = ::PlaceOS::Model::DoorkeeperApplication.new
39+
app.name = "refresh-scope-#{Random.rand(99999)}"
40+
app.redirect_uri = "https://app.example/cb"
41+
app.scopes = "public"
42+
app.owner_id = user.id.as(String)
43+
app.save!
44+
45+
cookie = Spec.signin!(client, user, password)
46+
authorize = client.get(
47+
"/auth/authorize?response_type=code" \
48+
"&client_id=#{URI.encode_www_form(app.uid.as(String))}" \
49+
"&redirect_uri=#{URI.encode_www_form("https://app.example/cb")}&scope=public",
50+
headers: HTTP::Headers{"Host" => "localhost", "Cookie" => cookie},
51+
)
52+
code = URI::Params.parse(authorize.headers["Location"].split('?', 2).last)["code"]
53+
54+
token = client.post("/auth/token", headers: HTTP::Headers{
55+
"Host" => "localhost", "Content-Type" => "application/x-www-form-urlencoded",
56+
}, body: URI::Params.build { |fp|
57+
fp.add("grant_type", "authorization_code")
58+
fp.add("client_id", app.uid.as(String))
59+
fp.add("client_secret", app.secret)
60+
fp.add("code", code)
61+
fp.add("redirect_uri", "https://app.example/cb")
62+
})
63+
token.status_code.should eq 200
64+
body = JSON.parse(token.body)
65+
{user, app, body["access_token"].as_s, body["refresh_token"].as_s}
66+
}
67+
68+
refresh = ->(app : ::PlaceOS::Model::DoorkeeperApplication, refresh_token : String) {
69+
result = client.post("/auth/token", headers: HTTP::Headers{
70+
"Host" => "localhost", "Content-Type" => "application/x-www-form-urlencoded",
71+
}, body: URI::Params.build { |fp|
72+
fp.add("grant_type", "refresh_token")
73+
fp.add("client_id", app.uid.as(String))
74+
fp.add("client_secret", app.secret)
75+
fp.add("refresh_token", refresh_token)
76+
})
77+
result.status_code.should eq 200
78+
JSON.parse(result.body)
79+
}
80+
81+
it "preserves scope across refresh (the 403 repro)" do
82+
user, app, access_token, refresh_token = login.call
83+
84+
# sanity: the freshly-minted token carries the granted scope
85+
scopes_of.call(decode.call(access_token)).should eq ["public"]
86+
87+
refreshed = refresh.call(app, refresh_token)
88+
claims = decode.call(refreshed["access_token"].as_s)
89+
90+
# THE BUG: currently this is [] -> rest-api can_read fails -> 403
91+
scopes_of.call(claims).should eq ["public"]
92+
ensure
93+
app.try &.destroy
94+
user.try &.destroy
95+
end
96+
97+
it "preserves scope across refresh-of-refresh" do
98+
user, app, _at, refresh_token = login.call
99+
first = refresh.call(app, refresh_token)
100+
second = refresh.call(app, first["refresh_token"].as_s)
101+
scopes_of.call(decode.call(second["access_token"].as_s)).should eq ["public"]
102+
ensure
103+
app.try &.destroy
104+
user.try &.destroy
105+
end
106+
end
107+
end

src/placeos-auth/authly_adapter.cr

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,18 @@ module Authly
159159
"jti" => Random::Secure.hex(32),
160160
"sub" => @client_id,
161161
"user_id" => uid,
162-
"name" => "refresh token",
163-
"iat" => Time.utc.to_unix,
164-
"iss" => Authly.config.issuer,
165-
"exp" => Authly.config.refresh_ttl.from_now.to_unix,
162+
# Embed the granted scope so it survives refresh. authly's refresh
163+
# grant otherwise derives scope only from the request (absent on a
164+
# standard refresh) or the auth code (absent on a refresh), leaving
165+
# the refreshed token with an empty scope — which fails rest-api's
166+
# `can_read` (needs `public`) and 403s every API call (PPT-2536).
167+
# Recovered in `Grant#scope` below. Mirrors Ruby Doorkeeper, which
168+
# reapplies the original grant's scope on refresh.
169+
"scope" => @scope,
170+
"name" => "refresh token",
171+
"iat" => Time.utc.to_unix,
172+
"iss" => Authly.config.issuer,
173+
"exp" => Authly.config.refresh_ttl.from_now.to_unix,
166174
})
167175
end
168176
end
@@ -179,6 +187,37 @@ module Authly
179187
nil
180188
end
181189
end
190+
191+
# Patch: carry the granted scope across a refresh. Upstream `Grant#scope`
192+
# returns the request scope (absent on a standard refresh), else the auth
193+
# code's scope (absent on a refresh), else "". So a refreshed access token
194+
# lost its scope entirely — emitting `scope: [] ` — and every downstream
195+
# API call 403'd on rest-api's `can_read` (which requires the `public`
196+
# scope). We recover the scope embedded in the refresh token by the
197+
# `AccessToken#initialize` patch above. An explicit (narrowing) request
198+
# scope still wins, per RFC 6749 §6.
199+
class Grant
200+
private def scope : String
201+
if (scp = @scope) && !scp.empty?
202+
return scp
203+
end
204+
unless @code.empty?
205+
return auth_code["scope"].as_s
206+
end
207+
# Refresh grant: no request scope, no auth code. Recover the scope the
208+
# `AccessToken#initialize` patch embedded in the refresh token. Guard
209+
# only this decode so the code-grant path keeps its original semantics.
210+
unless @refresh_token.empty?
211+
recovered = begin
212+
Authly.jwt_decode(@refresh_token).first["scope"]?.try(&.as_s?.presence)
213+
rescue
214+
nil
215+
end
216+
return recovered if recovered
217+
end
218+
""
219+
end
220+
end
182221
end
183222

184223
# Configure at require time so both the production binary (`src/app.cr`)

0 commit comments

Comments
 (0)