|
38 | 38 | import org.restheart.plugins.PluginsRegistry; |
39 | 39 | import org.restheart.plugins.RegisterPlugin; |
40 | 40 | import org.restheart.security.ACLRegistry; |
| 41 | +import org.restheart.security.WithProperties; |
41 | 42 | import org.restheart.security.interceptors.FormDataToBasicAuthInterceptor; |
42 | 43 | import org.restheart.security.tokens.JwtConfigProvider; |
43 | | -import org.restheart.security.tokens.JwtTokenManager; |
| 44 | +import org.restheart.security.tokens.JwtIssuer; |
44 | 45 | import org.restheart.utils.HttpStatus; |
45 | 46 | import org.slf4j.Logger; |
46 | 47 | import org.slf4j.LoggerFactory; |
47 | 48 |
|
48 | | -import com.auth0.jwt.JWT; |
49 | 49 | import com.auth0.jwt.algorithms.Algorithm; |
50 | 50 |
|
51 | 51 | import io.undertow.util.Headers; |
|
73 | 73 | * <p>The code JWT carries the following claims: |
74 | 74 | * <ul> |
75 | 75 | * <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> |
76 | 81 | * <li>{@code roles} — array of roles</li> |
77 | 82 | * <li>{@code cc} — code_challenge (PKCE)</li> |
78 | 83 | * <li>{@code ccm} — code_challenge_method</li> |
79 | 84 | * <li>{@code ruri} — redirect_uri</li> |
80 | 85 | * <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> |
82 | 87 | * </ul> |
83 | 88 | * |
84 | 89 | * @author Andrea Di Cesare {@literal <andrea@softinstigate.com>} |
@@ -118,18 +123,57 @@ public class OAuthAuthorizationService implements ByteArrayService { |
118 | 123 |
|
119 | 124 | private String loginUrl; |
120 | 125 | private List<String> allowedRedirectUris; |
121 | | - private Algorithm signingAlgo; |
| 126 | + private volatile JwtIssuer jwtIssuer; |
122 | 127 |
|
123 | 128 | @OnInit |
124 | 129 | public void init() { |
125 | 130 | this.loginUrl = argOrDefault(config, "login-url", null); |
126 | 131 | this.allowedRedirectUris = argOrDefault(config, "allowed-redirect-uris", List.of()); |
127 | | - this.signingAlgo = buildAlgorithm(jwtConfig); |
128 | 132 |
|
129 | 133 | // allow unauthenticated GET (redirect to login) and authenticated POST (issue code) |
130 | 134 | aclRegistry.registerAllow(req -> "/authorize".equals(req.getPath())); |
131 | 135 | } |
132 | 136 |
|
| 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 | + |
133 | 177 | @Override |
134 | 178 | public void handle(ByteArrayRequest request, ByteArrayResponse response) throws Exception { |
135 | 179 | switch (request.getMethod()) { |
@@ -269,26 +313,26 @@ private void handlePost(ByteArrayRequest request, ByteArrayResponse response) { |
269 | 313 |
|
270 | 314 | // Issue authorization code as a short-lived signed JWT. |
271 | 315 | // 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()) |
278 | 322 | .withClaim(CLAIM_CODE_CHALLENGE, codeChallenge) |
279 | 323 | .withClaim(CLAIM_CODE_CHALLENGE_METHOD, codeChallengeMethod) |
280 | 324 | .withClaim(CLAIM_REDIRECT_URI, redirectUri) |
281 | 325 | .withClaim(CLAIM_CLIENT_ID, clientId); |
282 | 326 |
|
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)); |
289 | 333 | } |
290 | 334 |
|
291 | | - var code = codeBuilder.sign(signingAlgo); |
| 335 | + var code = jwtIssuer.sign(codeBuilder); |
292 | 336 |
|
293 | 337 | var sb = new StringBuilder(redirectUri); |
294 | 338 | sb.append(redirectUri.contains("?") ? "&" : "?"); |
|
0 commit comments