Skip to content

Commit 1985565

Browse files
committed
fix(oauth): report access-token expires_in as the configured TTL
Authly's AccessToken#expires_in derives from an ACCESS_TTL constant captured at class-load time, before configure! applies the 2-hour access TTL, so the token response reported the authly default of 3600s while the JWT exp claim was a full 7200s. Report the live configured TTL so the RFC 6749 expires_in matches both the JWT exp and the legacy 2-hour Ruby service.
1 parent 6d0d2f6 commit 1985565

2 files changed

Lines changed: 51 additions & 4 deletions

File tree

spec/controllers/oauth_token_claims_spec.cr

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,49 @@ module PlaceOS::Auth
133133
end
134134
end
135135

136+
describe "token lifetime" do
137+
# The legacy Ruby service issues 2-hour (7200s) access tokens.
138+
# authly's AccessToken#expires_in captures the default 1-hour TTL at
139+
# class-load time (before configure! runs), so the response under-
140+
# reported the lifetime while the JWT exp claim was a full 2 hours.
141+
it "reports expires_in consistent with the JWT exp (~7200s)" do
142+
authority = ::PlaceOS::Model::Authority.find_by_domain("localhost").not_nil!
143+
user = ::PlaceOS::Model::Generator.user(authority).tap do |u|
144+
u.password = "ignored-#{Random.rand(99999)}"
145+
u.save!
146+
end
147+
app = ::PlaceOS::Model::DoorkeeperApplication.new
148+
app.name = "ttl-test-#{Random.rand(99999)}"
149+
app.redirect_uri = "https://app.example/cb"
150+
app.scopes = "public"
151+
app.owner_id = user.id.as(String)
152+
app.save!
153+
154+
headers = HTTP::Headers{
155+
"Host" => "localhost",
156+
"Content-Type" => "application/x-www-form-urlencoded",
157+
}
158+
body = URI::Params.build do |fp|
159+
fp.add("grant_type", "client_credentials")
160+
fp.add("client_id", app.uid.as(String))
161+
fp.add("client_secret", app.secret)
162+
fp.add("scope", "public")
163+
end
164+
result = client.post("/auth/token", headers: headers, body: body)
165+
result.status_code.should eq 200
166+
parsed = JSON.parse(result.body)
167+
168+
parsed["expires_in"].as_i64.should be >= 7100
169+
parsed["expires_in"].as_i64.should be <= 7200
170+
171+
claims = decode_claims.call(parsed["access_token"].as_s)
172+
(claims["exp"].as_i - claims["iat"].as_i).should be_close(7200, 5)
173+
ensure
174+
app.try &.destroy
175+
user.try &.destroy
176+
end
177+
end
178+
136179
describe "claim set" do
137180
# The legacy Ruby token carries no `cid` claim; authly adds one by
138181
# default. Drop it so the emitted claim set matches Doorkeeper's.

src/placeos-auth/controllers/oauth.cr

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,14 @@ module PlaceOS::Auth
3838
@id_token = at.id_token
3939
@scope = at.scope.presence
4040

41-
# `Authly::AccessToken#expires_in` is initialised to an
42-
# absolute unix timestamp, not the relative `expires_in` of
43-
# RFC 6749. Convert.
44-
@expires_in = (at.expires_in - Time.utc.to_unix).clamp(0_i64, Int64::MAX)
41+
# `Authly::AccessToken#expires_in` is an absolute unix timestamp
42+
# derived from a TTL constant captured at class-load time — before
43+
# our `configure!` sets the 2-hour access TTL — so it reports the
44+
# authly default (1 hour) and disagrees with the token's own `exp`
45+
# claim (which uses the live config). Report the configured TTL so
46+
# the relative RFC 6749 `expires_in` matches the JWT and the legacy
47+
# 2-hour service.
48+
@expires_in = ::Authly.config.access_ttl.total_seconds.to_i64
4549
end
4650
end
4751

0 commit comments

Comments
 (0)