001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.rest.server.auth.jwt; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020import static org.apache.juneau.commons.utils.StringUtils.*; 021 022import java.net.*; 023import java.security.*; 024import java.text.*; 025import java.time.*; 026import java.util.*; 027 028import org.apache.juneau.commons.inject.*; 029import org.apache.juneau.commons.utils.*; 030import org.apache.juneau.rest.server.auth.*; 031 032import com.nimbusds.jose.*; 033import com.nimbusds.jose.jwk.source.*; 034import com.nimbusds.jose.proc.*; 035import com.nimbusds.jwt.*; 036import com.nimbusds.jwt.proc.*; 037 038/** 039 * {@link TokenValidator} that validates signed JWTs against a JWKS-backed key source. 040 * 041 * <p> 042 * Designed to be paired with {@link BearerTokenGuard} in 043 * {@code juneau-rest-server}. It enforces a deliberately strict default policy so a misconfigured 044 * call site can't accidentally accept dangerous tokens: 045 * 046 * <ul> 047 * <li><b>Algorithm allowlisting</b> — defaults to {@code [RS256, ES256]}. {@code HS256} is rejected 048 * unless the caller explicitly opts in via 049 * {@link Builder#algorithms(JWSAlgorithm...) algorithms(...)}. {@code "none"} (unsigned JWTs) is 050 * <i>permanently</i> rejected — there is no opt-in. This defeats the well-known 051 * <i>algorithm confusion</i> attack where a JWT is signed with {@code HS256} using the RS256 052 * public key as the HMAC secret. 053 * <li><b>Mandatory claims</b> — {@code iss}, {@code aud}, {@code exp}, {@code nbf} are required by 054 * default. A token missing any of them is rejected. 055 * <li><b>Clock skew</b> — defaults to 60 seconds tolerance for {@code exp} and {@code nbf}. 056 * Capped at 300 seconds by the builder. 057 * <li><b>JWKS caching</b> — keys are fetched and cached for 5 minutes by default. On fetch failure 058 * the cache continues serving past TTL with a warning logged (see {@link JwksCache}). When a 059 * fresh-cache selection returns no keys (typically because an IdP rotated its signing key 060 * mid-TTL), the cache performs one eager out-of-band refresh before failing the request. This 061 * behavior is <b>enabled by default</b> ({@link Builder#jwksEagerRefreshOnKidMiss(boolean) 062 * jwksEagerRefreshOnKidMiss(true)}) and bounded by a per-cache cooldown (default 10 seconds) 063 * plus a single-in-flight guard. Opt out via 064 * {@link Builder#jwksEagerRefreshOnKidMiss(boolean) jwksEagerRefreshOnKidMiss(false)}. 065 * Applies only to {@link Builder#jwksUrl(URI) jwksUrl(...)}-backed caches; a 066 * caller-supplied {@link Builder#jwkSource(JWKSource) jwkSource(...)} is unaffected. 067 * </ul> 068 * 069 * <h5 class='topic'>Usage</h5> 070 * 071 * <p class='bjava'> 072 * <jk>var</jk> validator = JwtTokenValidator.<jsm>create</jsm>() 073 * .jwksUrl(URI.<jsm>create</jsm>(<js>"https://issuer.example.com/.well-known/jwks.json"</js>)) 074 * .issuer(<js>"https://issuer.example.com/"</js>) 075 * .audience(<js>"https://api.example.com"</js>) 076 * .build(); 077 * 078 * <jk>var</jk> guard = BearerTokenGuard.<jsm>create</jsm>() 079 * .realm(<js>"api"</js>) 080 * .validator(validator) 081 * .build(); 082 * </p> 083 * 084 * <h5 class='topic'>Security notes</h5> 085 * 086 * <ul> 087 * <li>Never disable {@code iss} / {@code aud} validation. The builder enforces them. 088 * <li>Allowlist {@code HS256} only when both ends of the connection are services you control AND 089 * you have a secure secret distribution mechanism. JWKS-published JWTs should be asymmetric. 090 * <li>Rotate JWKS keys on a schedule. {@link JwksCache} picks up new keys after the configured TTL. 091 * </ul> 092 * 093 * <h5 class='section'>See Also:</h5> 094 * <ul> 095 * <li class='jc'>{@link TokenValidator} 096 * <li class='jc'>{@link BearerTokenGuard} 097 * <li class='jc'>{@link ClaimsPrincipal} 098 * <li class='link'><a class="doclink" href="https://datatracker.ietf.org/doc/html/rfc7519">RFC 7519 — JSON Web Token</a> 099 * <li class='link'><a class="doclink" href="https://datatracker.ietf.org/doc/html/rfc7517">RFC 7517 — JSON Web Key</a> 100 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerAuthGuards">AuthN Guards — Bearer / API-Key / JWT</a> 101 * </ul> 102 * 103 * @since 10.0.0 104 */ 105@SuppressWarnings({ 106 "java:S1192" // Duplicate string literals are JWT claim names and algorithm identifiers; intentional 107}) 108public class JwtTokenValidator implements TokenValidator { 109 110 /** Maximum clock-skew tolerance the builder will accept. */ 111 private static final Duration MAX_CLOCK_SKEW = Duration.ofSeconds(300); 112 113 /** 114 * Static creator. 115 * 116 * <p> 117 * Routes builder construction through {@link BeanInstantiator} so the 118 * {@link Builder#jwksCacheTtl jwksCacheTtl} default is resolved via {@link Value @Value} from the 119 * active {@link org.apache.juneau.commons.settings.Settings Settings} chain 120 * ({@code juneau.jwt.jwksCacheTtl} property → {@code PT5M}). 121 * 122 * @return A new builder. 123 */ 124 public static Builder create() { 125 return create(BasicBeanStore.INSTANCE); 126 } 127 128 /** 129 * Static creator with explicit bean store. 130 * 131 * @param beanStore The bean store to use for dependency injection. 132 * @return A new builder. 133 */ 134 public static Builder create(BeanStore beanStore) { 135 return BeanInstantiator.of(Builder.class, beanStore).run(); 136 } 137 138 /** 139 * Builder class for {@link JwtTokenValidator}. 140 */ 141 public static class Builder { 142 143 private URI jwksUrl; 144 private JWKSource<SecurityContext> jwkSource; 145 private String issuer; 146 private String audience; 147 private Set<JWSAlgorithm> algorithms = new LinkedHashSet<>(Arrays.asList(JWSAlgorithm.RS256, JWSAlgorithm.ES256)); 148 private Duration clockSkew = Duration.ofSeconds(60); 149 150 /** 151 * JWKS cache TTL; populated via {@link Value @Value} from 152 * {@code juneau.jwt.jwksCacheTtl} (ISO-8601 duration, default {@code PT5M} = 5 minutes). 153 */ 154 @Value("${juneau.jwt.jwksCacheTtl:PT5M}") 155 Duration jwksCacheTtl; 156 157 /** 158 * Whether to perform an eager JWKS refresh on a {@code kid} cache miss; populated via 159 * {@link Value @Value} from {@code juneau.jwt.jwksEagerRefreshOnKidMiss} (boolean, default 160 * {@code true}). 161 */ 162 @Value("${juneau.jwt.jwksEagerRefreshOnKidMiss:true}") 163 boolean jwksEagerRefreshOnKidMiss; 164 165 /** 166 * Minimum interval between eager JWKS refreshes; populated via {@link Value @Value} from 167 * {@code juneau.jwt.jwksEagerRefreshCooldown} (ISO-8601 duration, default {@code PT10S} = 10 168 * seconds). 169 */ 170 @Value("${juneau.jwt.jwksEagerRefreshCooldown:PT10S}") 171 Duration jwksEagerRefreshCooldown; 172 173 private Clock clock = Clock.systemUTC(); 174 175 /** Constructor. */ 176 protected Builder() {} 177 178 /** 179 * Sets the JWKS endpoint URL the validator fetches keys from. 180 * 181 * <p> 182 * Mutually exclusive with {@link #jwkSource(JWKSource)} — set one or the other. 183 * 184 * <p> 185 * The URL must use <js>"https"</js> or target a loopback host; the signing-key material fetched from 186 * this endpoint is the trust anchor for every token, so a plaintext transport to a non-loopback host 187 * is rejected. 188 * 189 * @param value The JWKS URL. Must not be <jk>null</jk> and must use HTTPS or target a loopback host. 190 * @return This object. 191 */ 192 public Builder jwksUrl(URI value) { 193 jwksUrl = UriUtils.assertSecureOrLoopback(reqnn("value", value)); 194 return this; 195 } 196 197 /** 198 * Sets a custom {@link JWKSource} the validator fetches keys from. 199 * 200 * <p> 201 * Mutually exclusive with {@link #jwksUrl(URI)} — set one or the other. Useful for HSM-backed 202 * signers, multi-issuer federation, or unit tests that want to inject a deterministic key 203 * set without binding to a real HTTPS endpoint. 204 * 205 * @param value The JWK source. Must not be <jk>null</jk>. 206 * @return This object. 207 */ 208 public Builder jwkSource(JWKSource<SecurityContext> value) { 209 jwkSource = reqnn("value", value); 210 return this; 211 } 212 213 /** 214 * Sets the required {@code iss} (issuer) claim value. 215 * 216 * @param value The issuer. Must not be <jk>null</jk> or blank. 217 * @return This object. 218 */ 219 public Builder issuer(String value) { 220 issuer = reqnb("value", value); 221 return this; 222 } 223 224 /** 225 * Sets the required {@code aud} (audience) claim value. 226 * 227 * @param value The audience. Must not be <jk>null</jk> or blank. 228 * @return This object. 229 */ 230 public Builder audience(String value) { 231 audience = reqnb("value", value); 232 return this; 233 } 234 235 /** 236 * Sets the signing-algorithm allowlist. 237 * 238 * <p> 239 * Replaces (not adds to) the default allowlist. Callers wanting {@code HS256} support must 240 * include it explicitly. {@link JWSAlgorithm#NONE} is rejected at build time and at runtime 241 * regardless of what is passed here. 242 * 243 * @param values The allowed algorithms. Must contain at least one element; must not contain 244 * {@link JWSAlgorithm#NONE}. 245 * @return This object. 246 */ 247 public Builder algorithms(JWSAlgorithm...values) { 248 reqnn("values", values); 249 if (values.length == 0) 250 throw iaex("algorithms allowlist must be non-empty"); 251 Set<JWSAlgorithm> next = st(); 252 for (var a : values) { 253 reqnn("algorithm", a); 254 if (Algorithm.NONE.equals(a)) 255 throw iaex("\"none\" algorithm is permanently rejected (RFC 7518 §3.6 unsafe)"); 256 next.add(a); 257 } 258 algorithms = next; 259 return this; 260 } 261 262 /** 263 * Sets the clock-skew tolerance for {@code exp} / {@code nbf} validation. 264 * 265 * @param value The tolerance. Must be non-negative and not exceed 5 minutes. 266 * @return This object. 267 */ 268 public Builder clockSkew(Duration value) { 269 reqnn("value", value); 270 if (value.isNegative()) 271 throw iaex("clockSkew must be non-negative"); 272 if (value.compareTo(MAX_CLOCK_SKEW) > 0) 273 throw iaex("clockSkew must not exceed 5 minutes (was %s)", value); 274 clockSkew = value; 275 return this; 276 } 277 278 /** 279 * Sets the JWKS cache TTL. 280 * 281 * @param value The TTL. Must be positive. 282 * @return This object. 283 */ 284 public Builder jwksCacheTtl(Duration value) { 285 reqnn("value", value); 286 if (value.isZero() || value.isNegative()) 287 throw iaex("jwksCacheTtl must be positive"); 288 jwksCacheTtl = value; 289 return this; 290 } 291 292 /** 293 * Enables or disables the eager JWKS refresh that fires on a fresh-cache {@code kid} miss. 294 * 295 * <p> 296 * When {@code true} (the default), the first request that presents a token whose signing 297 * key is not in the current (still-fresh) cache triggers a single out-of-band JWKS refresh. 298 * This closes the rotation window where an IdP publishes a new key mid-TTL and clients would 299 * otherwise see up to {@code jwksCacheTtl} minutes of spurious 401 responses for perfectly 300 * valid tokens. The refresh is bounded by {@link #jwksEagerRefreshCooldown(Duration)} and a 301 * single-in-flight guard (no thundering herd). 302 * 303 * <p> 304 * Applies only to {@link #jwksUrl(URI) jwksUrl(...)}-backed caches. A 305 * caller-supplied {@link #jwkSource(JWKSource) jwkSource(...)} is unaffected. 306 * 307 * @param value {@code true} to enable (default); {@code false} to restore pre-10.0.0 behavior. 308 * @return This object. 309 */ 310 public Builder jwksEagerRefreshOnKidMiss(boolean value) { 311 jwksEagerRefreshOnKidMiss = value; 312 return this; 313 } 314 315 /** 316 * Sets the minimum spacing between consecutive eager JWKS refreshes. 317 * 318 * <p> 319 * A burst of unknown-{@code kid} tokens (e.g. misconfigured client, replay attack) or a 320 * JWKS-endpoint outage will trigger at most one eager refresh per cooldown window. This 321 * prevents the eager-refresh path from becoming a vector for amplified JWKS-endpoint load. 322 * 323 * <p> 324 * Must be positive, at most 60 seconds, and at most {@link #jwksCacheTtl(Duration) 325 * jwksCacheTtl} (validated at {@link #build()} time). 326 * 327 * @param value The cooldown. Must not be {@code null}. 328 * @return This object. 329 */ 330 public Builder jwksEagerRefreshCooldown(Duration value) { 331 reqnn("value", value); 332 if (value.isZero() || value.isNegative()) 333 throw iaex("jwksEagerRefreshCooldown must be positive"); 334 if (value.compareTo(Duration.ofSeconds(60)) > 0) 335 throw iaex("jwksEagerRefreshCooldown must not exceed 60 seconds (was %s)", value); 336 jwksEagerRefreshCooldown = value; 337 return this; 338 } 339 340 /** 341 * Overrides the {@link Clock} used for {@code exp} / {@code nbf} comparisons and JWKS cache 342 * TTL. Useful in tests. 343 * 344 * @param value The clock. Must not be <jk>null</jk>. 345 * @return This object. 346 */ 347 public Builder clock(Clock value) { 348 clock = reqnn("value", value); 349 return this; 350 } 351 352 /** 353 * Builds the validator. 354 * 355 * @return A new {@link JwtTokenValidator}. 356 */ 357 public JwtTokenValidator build() { 358 if (issuer == null) 359 throw isex("JwtTokenValidator requires an issuer"); 360 if (audience == null) 361 throw isex("JwtTokenValidator requires an audience"); 362 if (jwksUrl == null && jwkSource == null) 363 throw isex("JwtTokenValidator requires either jwksUrl(...) or jwkSource(...)"); 364 if (jwksUrl != null && jwkSource != null) 365 throw isex("JwtTokenValidator: jwksUrl(...) and jwkSource(...) are mutually exclusive"); 366 if (jwksEagerRefreshCooldown.compareTo(jwksCacheTtl) > 0) 367 throw isex("jwksEagerRefreshCooldown (%s) must not exceed jwksCacheTtl (%s)", jwksEagerRefreshCooldown, jwksCacheTtl); 368 return new JwtTokenValidator(this); 369 } 370 } 371 372 private final String issuer; 373 private final String audience; 374 private final Set<JWSAlgorithm> algorithms; 375 private final Duration clockSkew; 376 private final Clock clock; 377 private final JWKSource<SecurityContext> jwkSource; 378 379 /** 380 * Constructor. 381 * 382 * @param builder The builder to read configuration from. 383 */ 384 protected JwtTokenValidator(Builder builder) { 385 this.issuer = builder.issuer; 386 this.audience = builder.audience; 387 this.algorithms = u(cp(builder.algorithms)); 388 this.clockSkew = builder.clockSkew; 389 this.clock = builder.clock; 390 this.jwkSource = builder.jwkSource != null 391 ? builder.jwkSource 392 : new JwksCache(createRemoteJwkSource(builder.jwksUrl), builder.jwksCacheTtl, builder.clock, 393 builder.jwksEagerRefreshOnKidMiss, builder.jwksEagerRefreshCooldown); 394 } 395 396 /** 397 * Returns the issuer this validator requires on tokens. 398 * 399 * @return The configured {@code iss} value. 400 */ 401 public String getIssuer() { 402 return issuer; 403 } 404 405 /** 406 * Returns the audience this validator requires on tokens. 407 * 408 * @return The configured {@code aud} value. 409 */ 410 public String getAudience() { 411 return audience; 412 } 413 414 /** 415 * Returns the allowlist of signing algorithms. 416 * 417 * @return An unmodifiable copy of the allowlist. 418 */ 419 public Set<JWSAlgorithm> getAlgorithms() { 420 return algorithms; 421 } 422 423 /** 424 * Returns the configured clock-skew tolerance. 425 * 426 * @return The clock skew duration. 427 */ 428 public Duration getClockSkew() { 429 return clockSkew; 430 } 431 432 @Override /* Overridden from TokenValidator */ 433 public Principal validate(String token) throws AuthenticationException { 434 reqnn("token", token); 435 JWT jwt; 436 try { 437 jwt = JWTParser.parse(token); 438 } catch (ParseException e) { 439 throw new AuthenticationException(e, "JWT could not be parsed") 440 .wwwAuthenticate("Bearer error=\"invalid_token\", error_description=\"malformed JWT\""); 441 } 442 if (! (jwt instanceof SignedJWT jwt2)) 443 throw new AuthenticationException("JWT must be signed (alg=none and unsigned JWTs are rejected)") 444 .wwwAuthenticate("Bearer error=\"invalid_token\", error_description=\"unsigned JWT\""); 445 var alg = jwt2.getHeader().getAlgorithm(); 446 if (alg == null || Algorithm.NONE.equals(alg)) 447 throw new AuthenticationException("JWT alg \"none\" is not permitted") 448 .wwwAuthenticate("Bearer error=\"invalid_token\", error_description=\"alg=none rejected\""); 449 if (! algorithms.contains(alg)) 450 throw new AuthenticationException("JWT algorithm not allowlisted: " + alg) 451 .wwwAuthenticate("Bearer error=\"invalid_token\", error_description=\"algorithm not allowed\""); 452 453 // Verify signature. 454 var processor = new DefaultJWTProcessor<SecurityContext>(); 455 processor.setJWSKeySelector(new JWSVerificationKeySelector<>(algorithms, jwkSource)); 456 // Disable nimbus' internal claims verifier — we run our own below so we control the Clock. 457 processor.setJWTClaimsSetVerifier((claims, ctx) -> { /* no-op */ }); 458 459 JWTClaimsSet claims; 460 try { 461 claims = processor.process(jwt2, null); 462 } catch (BadJOSEException e) { 463 throw new AuthenticationException(e, "JWT validation failed: " + e.getMessage()) 464 .wwwAuthenticate("Bearer error=\"invalid_token\", error_description=\"" + sanitize(e.getMessage()) + "\""); 465 } catch (JOSEException e) { 466 throw new AuthenticationException(e, "JWT signature verification failed") 467 .wwwAuthenticate("Bearer error=\"invalid_token\", error_description=\"signature verification failed\""); 468 } 469 470 verifyClaims(claims); 471 472 var subject = claims.getSubject(); 473 if (isBlank(subject)) 474 subject = "<no-sub>"; 475 return new ClaimsPrincipal(subject, claims.toJSONObject()); 476 } 477 478 private void verifyClaims(JWTClaimsSet claims) throws AuthenticationException { 479 // Required claims: iss, aud, exp, nbf are non-negotiable defaults. 480 var iss = claims.getIssuer(); 481 if (isBlank(iss)) 482 throw reject("\"iss\" claim is required"); 483 if (neq(issuer, iss)) 484 throw reject("\"iss\" claim does not match expected issuer"); 485 486 var aud = claims.getAudience(); 487 if (ie(aud)) 488 throw reject("\"aud\" claim is required"); 489 if (! aud.contains(audience)) 490 throw reject("\"aud\" claim does not include expected audience"); 491 492 var exp = claims.getExpirationTime(); 493 if (exp == null) 494 throw reject("\"exp\" claim is required"); 495 var nbf = claims.getNotBeforeTime(); 496 if (nbf == null) 497 throw reject("\"nbf\" claim is required"); 498 499 var now = clock.instant(); 500 if (exp.toInstant().plus(clockSkew).isBefore(now)) 501 throw reject("token has expired"); 502 if (nbf.toInstant().minus(clockSkew).isAfter(now)) 503 throw reject("token not yet valid"); 504 } 505 506 private static AuthenticationException reject(String reason) { 507 return new AuthenticationException("JWT validation failed: " + reason) 508 .wwwAuthenticate("Bearer error=\"invalid_token\", error_description=\"" + sanitize(reason) + "\""); 509 } 510 511 private static JWKSource<SecurityContext> createRemoteJwkSource(URI jwksUrl) { 512 try { 513 return JWKSourceBuilder.create(jwksUrl.toURL()).build(); 514 } catch (MalformedURLException e) { 515 throw iaex(e, "Invalid JWKS URL: %s", jwksUrl); 516 } 517 } 518 519 private static String sanitize(String s) { 520 // Don't echo arbitrary characters into a WWW-Authenticate header value. 521 // Both call sites pass a non-null message: reject(String) always passes a string literal, and the 522 // BadJOSEException branch in validate() only ever catches Nimbus exceptions thrown (for a SignedJWT) with a 523 // literal, non-null message. 524 return s.replaceAll("[\"\\r\\n]", " "); 525 } 526}