Skip to content

Commit bf516dc

Browse files
committed
Refactor JWT issuance for claims override
1 parent 943c479 commit bf516dc

4 files changed

Lines changed: 113 additions & 28 deletions

File tree

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,38 @@ Feature: override-accounts-account-properties-claims per-request + JWT claims de
7676
# The override REPLACES the static list — "teams" (from the static default) must
7777
# not leak in either, since it was not requested in this override.
7878
* match payload.teams == '#notpresent'
79+
80+
# ---------------------------------------------------------------------------
81+
Scenario: /token applies the same per-request override and denylist
82+
# ---------------------------------------------------------------------------
83+
# Authenticate as a file-realm user that has a "profile" property,
84+
# via /token (JwtTokenManager path) with the override.
85+
* def creds = 'claimsTest:ClaimsPass123!'
86+
* def Base64 = Java.type('java.util.Base64')
87+
* def encoded = Base64.getEncoder().encodeToString(creds.getBytes())
88+
89+
Given path '/token'
90+
And header Authorization = 'Basic ' + encoded
91+
And param _claims-override = 'profile,password,emailVerificationToken'
92+
When method POST
93+
Then status 200
94+
And match response.access_token == '#present'
95+
96+
# Decode the JWT
97+
* def jwtPart = response.access_token
98+
* def parts = jwtPart.split('.')
99+
* def payloadJson = new java.lang.String(java.util.Base64.getUrlDecoder().decode(parts[1]))
100+
* def payload = JSON.parse(payloadJson)
101+
* karate.log('JWT payload from /token:', payload)
102+
103+
# Override took effect: "profile" is NOT in the static default list (teams),
104+
# so its presence proves the per-request override was applied by JwtTokenManager.
105+
* match payload.profile == '#present'
106+
* match payload.profile.name == 'FileClaims'
107+
108+
# Denylist cannot be bypassed
109+
* match payload.password == '#notpresent'
110+
* match payload.emailVerificationToken == '#notpresent'
111+
112+
# Override REPLACES the static list — default claims must not leak
113+
* match payload.teams == '#notpresent'

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
/jwtTokenManager/enabled: true
2424
/jwtTokenManager/ttl: 15
2525
/jwtTokenManager/account-properties-claims:
26-
- tenants
26+
- teams
2727

2828
/authCookieSetter/enabled: true
2929
/authCookieSetter/ttl: 15
@@ -39,6 +39,13 @@
3939
password: secret
4040
roles: [ test ]
4141

42+
- userid: claimsTest
43+
password: ClaimsPass123!
44+
roles: [ user ]
45+
profile:
46+
name: FileClaims
47+
teams: [ acme ]
48+
4249
- userid: testWithArray
4350
password: secret
4451
array: [ one, two, three ]

security/src/main/java/org/restheart/security/authenticators/MongoRealmAuthenticator.java

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -451,10 +451,13 @@ private MongoRealmAccount getAccount(final String usersDb, final String id) {
451451
}
452452

453453
/**
454-
* if client authenticates passing the real credentials, update the account
455-
* in the auth-token cache, otherwise the client authenticating with the
456-
* auth-token will not see roles updates until the cache expires (by default
457-
* TTL is 15 minutes after last request)
454+
* When a client authenticates with real credentials (Basic Auth), invalidate the
455+
* cached auth-token so that the next token generation picks up fresh roles and
456+
* account-properties-claims from the request context. Simply removing the stale
457+
* entry is better than regenerating it here: this method runs before
458+
* {@code TokenInjector} attaches the per-request claim list, so any token built
459+
* at this point would use the node-wide default instead of the tenant-specific
460+
* override.
458461
*
459462
* @param account
460463
*/
@@ -463,11 +466,7 @@ private void updateAuthTokenCache(final PwdCredentialAccount account) {
463466
final var _tm = registry.getTokenManager();
464467

465468
if (_tm != null) {
466-
final var tm = _tm.getInstance();
467-
468-
if (tm.get(account) != null) {
469-
tm.update(account);
470-
}
469+
_tm.getInstance().invalidate(account);
471470
}
472471
} catch (final ConfigurationException pce) {
473472
LOGGER.warn("error getting the token manager", pce);

security/src/main/java/org/restheart/security/services/OAuthAuthorizationService.java

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,14 @@
3838
import org.restheart.plugins.PluginsRegistry;
3939
import org.restheart.plugins.RegisterPlugin;
4040
import org.restheart.security.ACLRegistry;
41+
import org.restheart.security.WithProperties;
4142
import org.restheart.security.interceptors.FormDataToBasicAuthInterceptor;
4243
import org.restheart.security.tokens.JwtConfigProvider;
43-
import org.restheart.security.tokens.JwtTokenManager;
44+
import org.restheart.security.tokens.JwtIssuer;
4445
import org.restheart.utils.HttpStatus;
4546
import org.slf4j.Logger;
4647
import org.slf4j.LoggerFactory;
4748

48-
import com.auth0.jwt.JWT;
4949
import com.auth0.jwt.algorithms.Algorithm;
5050

5151
import io.undertow.util.Headers;
@@ -73,12 +73,17 @@
7373
* <p>The code JWT carries the following claims:
7474
* <ul>
7575
* <li>{@code sub} — username</li>
76+
* <li>{@code iss} — issuer (from jwtConfigProvider)</li>
77+
* <li>{@code aud} — audience (from jwtConfigProvider, when configured)</li>
78+
* <li>{@code jti} — unique JWT identifier</li>
79+
* <li>{@code iat} — issued-at timestamp</li>
80+
* <li>{@code exp} — expiry ({@value #CODE_TTL_MINUTES} minutes)</li>
7681
* <li>{@code roles} — array of roles</li>
7782
* <li>{@code cc} — code_challenge (PKCE)</li>
7883
* <li>{@code ccm} — code_challenge_method</li>
7984
* <li>{@code ruri} — redirect_uri</li>
8085
* <li>{@code cid} — client_id</li>
81-
* <li>{@code exp} — expiry ({@value #CODE_TTL_MINUTES} minutes)</li>
86+
* <li>account-properties-claims (from {@link JwtIssuer})</li>
8287
* </ul>
8388
*
8489
* @author Andrea Di Cesare {@literal <andrea@softinstigate.com>}
@@ -118,18 +123,57 @@ public class OAuthAuthorizationService implements ByteArrayService {
118123

119124
private String loginUrl;
120125
private List<String> allowedRedirectUris;
121-
private Algorithm signingAlgo;
126+
private volatile JwtIssuer jwtIssuer;
122127

123128
@OnInit
124129
public void init() {
125130
this.loginUrl = argOrDefault(config, "login-url", null);
126131
this.allowedRedirectUris = argOrDefault(config, "allowed-redirect-uris", List.of());
127-
this.signingAlgo = buildAlgorithm(jwtConfig);
128132

129133
// allow unauthenticated GET (redirect to login) and authenticated POST (issue code)
130134
aclRegistry.registerAllow(req -> "/authorize".equals(req.getPath()));
131135
}
132136

137+
/**
138+
* The shared JWT issuance policy. Built lazily: resolving the password property name
139+
* needs {@code mongoRealmAuthenticator}, which may not be initialized when this plugin's
140+
* {@code @OnInit} runs.
141+
*/
142+
private JwtIssuer issuer() {
143+
var local = this.jwtIssuer;
144+
145+
if (local == null) {
146+
synchronized (this) {
147+
local = this.jwtIssuer;
148+
if (local == null) {
149+
var algo = buildAlgorithm(jwtConfig);
150+
local = new JwtIssuer(algo, jwtConfig.issuer(), jwtConfig.audience(),
151+
jwtConfig.accountPropertiesClaims(), resolvePasswordPropertyName());
152+
this.jwtIssuer = local;
153+
}
154+
}
155+
}
156+
157+
return local;
158+
}
159+
160+
private String resolvePasswordPropertyName() {
161+
try {
162+
var pr = registry.getAuthenticator("mongoRealmAuthenticator");
163+
if (pr != null && pr.isEnabled()
164+
&& pr.getInstance() instanceof org.restheart.security.authenticators.MongoRealmAuthenticator mra) {
165+
var prop = mra.getPropPassword();
166+
if (prop != null && !prop.isBlank()) {
167+
return prop;
168+
}
169+
}
170+
} catch (Exception e) {
171+
LOGGER.debug("Could not resolve mongoRealmAuthenticator/prop-password, using default", e);
172+
}
173+
174+
return JwtIssuer.DEFAULT_PASSWORD_PROPERTY;
175+
}
176+
133177
@Override
134178
public void handle(ByteArrayRequest request, ByteArrayResponse response) throws Exception {
135179
switch (request.getMethod()) {
@@ -269,26 +313,26 @@ private void handlePost(ByteArrayRequest request, ByteArrayResponse response) {
269313

270314
// Issue authorization code as a short-lived signed JWT.
271315
// Stateless: any node sharing the same JWT key can later verify it.
272-
var roles = account.getRoles().toArray(String[]::new);
273-
var codeBuilder = JWT.create()
274-
.withIssuer(jwtConfig.issuer())
275-
.withSubject(account.getPrincipal().getName())
276-
.withExpiresAt(Date.from(Instant.now().plus(CODE_TTL_MINUTES, ChronoUnit.MINUTES)))
277-
.withArrayClaim(CLAIM_ROLES, roles)
316+
var jwtIssuer = issuer();
317+
var codeBuilder = jwtIssuer.newBuilder(
318+
account.getPrincipal().getName(),
319+
account.getRoles(),
320+
Date.from(Instant.now().plus(CODE_TTL_MINUTES, ChronoUnit.MINUTES)))
321+
.withIssuedAt(Instant.now())
278322
.withClaim(CLAIM_CODE_CHALLENGE, codeChallenge)
279323
.withClaim(CLAIM_CODE_CHALLENGE_METHOD, codeChallengeMethod)
280324
.withClaim(CLAIM_REDIRECT_URI, redirectUri)
281325
.withClaim(CLAIM_CLIENT_ID, clientId);
282326

283-
// Propagate account-properties-claims via JwtTokenManager so the logic stays in one place.
284-
// The request carries the effective claim list on a multi-tenant deployment: the access
285-
// token is later built from this code's payload, so a claim dropped here is lost for good.
286-
var tokenMgr = registry.getTokenManager();
287-
if (tokenMgr != null && tokenMgr.getInstance() instanceof JwtTokenManager jtm) {
288-
codeBuilder = jtm.withAccountPropertiesClaims(codeBuilder, account, request);
327+
// Propagate account-properties-claims so the access token (later built from this
328+
// code's payload) carries the same claims. The request carries the effective claim
329+
// list on a multi-tenant deployment.
330+
if (account instanceof WithProperties<?> awp) {
331+
codeBuilder = jwtIssuer.applyAccountClaims(codeBuilder, awp.propertiesAsMap(),
332+
JwtIssuer.claimsOverride(request));
289333
}
290334

291-
var code = codeBuilder.sign(signingAlgo);
335+
var code = jwtIssuer.sign(codeBuilder);
292336

293337
var sb = new StringBuilder(redirectUri);
294338
sb.append(redirectUri.contains("?") ? "&" : "?");

0 commit comments

Comments
 (0)