Skip to content

Commit 9ea379c

Browse files
committed
Fix JWT claim override for required claims
1 parent 55bb7fe commit 9ea379c

2 files changed

Lines changed: 123 additions & 5 deletions

File tree

core/src/test/java/karate/accounts/account-properties-claims-override.feature

Lines changed: 118 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,10 @@ Feature: override-accounts-account-properties-claims per-request + JWT claims de
7373
* match payload.password == '#notpresent'
7474
* match payload.emailVerificationToken == '#notpresent'
7575

76-
# The override REPLACES the static list — "teams" (from the static default) must
77-
# not leak in either, since it was not requested in this override.
78-
* match payload.teams == '#notpresent'
76+
# The override REPLACES the static list, but it cannot lower the required-claims floor:
77+
# "teams" is listed in jwtConfigProvider/required-account-properties-claims, so it is
78+
# present even though this override never asked for it. See the dedicated scenario below.
79+
* match payload.teams == '#present'
7980

8081
# ---------------------------------------------------------------------------
8182
Scenario: /token applies the same per-request override and denylist
@@ -109,5 +110,117 @@ Feature: override-accounts-account-properties-claims per-request + JWT claims de
109110
* match payload.password == '#notpresent'
110111
* match payload.emailVerificationToken == '#notpresent'
111112

112-
# Override REPLACES the static list — default claims must not leak
113-
* match payload.teams == '#notpresent'
113+
# Override REPLACES the static list, but required claims survive it — see the
114+
# dedicated scenario below.
115+
* match payload.teams == '#present'
116+
117+
# ---------------------------------------------------------------------------
118+
Scenario: /token resolves claims from a MongoRealmAccount, not just a file-realm one
119+
# ---------------------------------------------------------------------------
120+
# Regression test. The two scenarios above cover /auth/verify (a user document read
121+
# from MongoDB) and /token (a file-realm user). The combination left untested —
122+
# /token with a *MongoRealmAccount* — was broken for a whole release: claim selection
123+
# went through a JXPath helper meant for the YAML configuration tree, which resolved
124+
# against the plain map a FileRealmAccount hands back but not against the map
125+
# MongoRealmAccount rebuilds through GSON. It failed silently, since that helper
126+
# swallows every error and returns null.
127+
* def email = 'claims-mongo-' + java.util.UUID.randomUUID() + '@example.com'
128+
* def password = 'Password123!'
129+
130+
Given path '/auth/register'
131+
And request
132+
"""
133+
{
134+
"firstName": "Mongo",
135+
"lastName": "Claims",
136+
"teamName": "Mongo Claims Co",
137+
"email": "#(email)",
138+
"password": "#(password)"
139+
}
140+
"""
141+
When method POST
142+
Then status 201
143+
144+
Given path '/users/' + email
145+
And header Authorization = adminAuth
146+
When method GET
147+
Then status 200
148+
* def verificationToken = response.emailVerificationToken
149+
150+
Given path '/auth/verify'
151+
And param email = email
152+
And param token = verificationToken
153+
When method GET
154+
Then status 302
155+
156+
# Give the user a nested profile field to select as a claim
157+
Given path '/users/' + email
158+
And header Authorization = adminAuth
159+
And request { "profile": { "name": "MongoClaims" } }
160+
When method PATCH
161+
Then status 200
162+
163+
# Authenticate with credentials: the account is a MongoRealmAccount, so its properties
164+
# are the user document — the case that used to yield no claims at all.
165+
* def Base64 = Java.type('java.util.Base64')
166+
* def encoded = Base64.getEncoder().encodeToString((email + ':' + password).getBytes())
167+
168+
Given path '/token'
169+
And header Authorization = 'Basic ' + encoded
170+
And param _claims-override = 'profile'
171+
When method POST
172+
Then status 200
173+
174+
* def parts = response.access_token.split('.')
175+
* def payload = JSON.parse(new java.lang.String(java.util.Base64.getUrlDecoder().decode(parts[1])))
176+
* karate.log('JWT payload from /token (mongo realm):', payload)
177+
178+
* match payload.profile == '#present'
179+
* match payload.profile.name == 'MongoClaims'
180+
181+
# ---------------------------------------------------------------------------
182+
Scenario: required claims survive an override that does not list them
183+
# ---------------------------------------------------------------------------
184+
# required-account-properties-claims is the floor a per-request override cannot lower.
185+
# It exists because a deployment can depend on a claim being there — a multi-tenant node
186+
# verifies the claim naming the issuing node on every later request, so a tenant able to
187+
# drop it would lock itself out with a config change.
188+
#
189+
# conf-overrides.yml sets jwtConfigProvider/required-account-properties-claims to include
190+
# "teams"; the override below asks for "profile" only.
191+
* def creds = 'claimsTest:ClaimsPass123!'
192+
* def Base64 = Java.type('java.util.Base64')
193+
* def encoded = Base64.getEncoder().encodeToString(creds.getBytes())
194+
195+
Given path '/token'
196+
And header Authorization = 'Basic ' + encoded
197+
And param _claims-override = 'profile'
198+
When method POST
199+
Then status 200
200+
201+
* def parts = response.access_token.split('.')
202+
* def payload = JSON.parse(new java.lang.String(java.util.Base64.getUrlDecoder().decode(parts[1])))
203+
* karate.log('JWT payload with required claims:', payload)
204+
205+
# The override was applied ...
206+
* match payload.profile == '#present'
207+
# ... and the required claim is there anyway, though the override never listed it
208+
* match payload.teams == '#present'
209+
210+
# ---------------------------------------------------------------------------
211+
Scenario: the denylist wins over required claims
212+
# ---------------------------------------------------------------------------
213+
# A credential must not become a claim even if configuration declares it required.
214+
* def creds = 'claimsTest:ClaimsPass123!'
215+
* def Base64 = Java.type('java.util.Base64')
216+
* def encoded = Base64.getEncoder().encodeToString(creds.getBytes())
217+
218+
Given path '/token'
219+
And header Authorization = 'Basic ' + encoded
220+
And param _claims-override = 'password'
221+
When method POST
222+
Then status 200
223+
224+
* def parts = response.access_token.split('.')
225+
* def payload = JSON.parse(new java.lang.String(java.util.Base64.getUrlDecoder().decode(parts[1])))
226+
* match payload.password == '#notpresent'

core/src/test/resources/etc/conf-overrides.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
/jwtConfigProvider/algorithm: HS256
2020
/jwtConfigProvider/issuer: restheart.org
2121
/jwtConfigProvider/audience: null
22+
# Claims that survive a per-request override — see the account-properties-claims-override
23+
# feature. "teams" stands in for the claim a real deployment cannot lose (on a multi-tenant
24+
# node, the one naming the issuing node, verified on every later request).
25+
/jwtConfigProvider/required-account-properties-claims:
26+
- teams
2227

2328
/jwtTokenManager/enabled: true
2429
/jwtTokenManager/ttl: 15

0 commit comments

Comments
 (0)