diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java index 7cb3e224b1..6570538fe4 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java @@ -706,8 +706,10 @@ public final class OAuth2ResourceServerConfigurer oauth2 .jwt(Customizer.withDefaults()) - .dPoP(Customizer.withDefaults())); - + .dPoP(Customizer.withDefaults()) + .withObjectPostProcessor(dPoPProofVerifierFactoryCustomizer())); // @formatter:on return http.build(); } @@ -258,6 +266,25 @@ public class DPoPAuthenticationTests { return NimbusJwtDecoder.withPublicKey(PROVIDER_RSA_PUBLIC_KEY).build(); } + private ObjectPostProcessor dPoPProofVerifierFactoryCustomizer() { + return new ObjectPostProcessor<>() { + @Override + public O postProcess(O authenticationProvider) { + DPoPProofReplayValidator.InMemoryCache inMemoryCache = new DPoPProofReplayValidator.InMemoryCache(); + inMemoryCache.setMaxSize(50_000); + inMemoryCache.setMaxRequestsPerKey(500); + DPoPProofReplayValidator dPoPProofReplayValidator = new DPoPProofReplayValidator(inMemoryCache); + dPoPProofReplayValidator.setClockSkew(Duration.ofSeconds(60)); + Function> jwtValidatorFactory = DPoPProofJwtDecoderFactory + .createDefaultJwtValidatorFactory(Collections.singletonList(dPoPProofReplayValidator)); + DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory(); + dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory); + authenticationProvider.setDPoPProofVerifierFactory(dPoPProofJwtDecoderFactory); + return authenticationProvider; + } + }; + } + } @RestController diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidator.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidator.java index 17a7cd0896..3357ae5f72 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidator.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidator.java @@ -33,6 +33,8 @@ public final class DelegatingOAuth2TokenValidator impleme private final Collection> tokenValidators; + private boolean failOnError; + /** * Constructs a {@code DelegatingOAuth2TokenValidator} using the provided validators. * @param tokenValidators the {@link Collection} of {@link OAuth2TokenValidator}s to @@ -57,8 +59,20 @@ public final class DelegatingOAuth2TokenValidator impleme Collection errors = new ArrayList<>(); for (OAuth2TokenValidator validator : this.tokenValidators) { errors.addAll(validator.validate(token).getErrors()); + if (!errors.isEmpty() && this.failOnError) { + return OAuth2TokenValidatorResult.failure(errors); + } } return OAuth2TokenValidatorResult.failure(errors); } + /** + * Fail-fast when a delegate errors, defaults to {@code false}. + * @param failOnError fail-fast when a delegate errors + * @since 6.5.12 + */ + public void setFailOnError(boolean failOnError) { + this.failOnError = failOnError; + } + } diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofJwtDecoderFactory.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofJwtDecoderFactory.java index de88ba57ae..2a6596beb6 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofJwtDecoderFactory.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofJwtDecoderFactory.java @@ -18,11 +18,11 @@ package org.springframework.security.oauth2.jwt; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; -import java.time.Instant; -import java.time.temporal.ChronoUnit; +import java.time.Duration; +import java.util.ArrayList; import java.util.Base64; import java.util.Collections; -import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.function.Function; @@ -39,12 +39,15 @@ import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import org.springframework.security.oauth2.core.ClaimAccessor; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2Token; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; /** @@ -63,10 +66,11 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory} factory that validates the - * {@code htm}, {@code htu}, {@code jti} and {@code iat} claims of the DPoP Proof - * {@link Jwt}. + * {@code htm}, {@code htu}, {@code iat}, {@code jkt}, {@code ath} and {@code jti} + * claims of the DPoP Proof {@link Jwt}. */ - public static final Function> DEFAULT_JWT_VALIDATOR_FACTORY = defaultJwtValidatorFactory(); + public static final Function> DEFAULT_JWT_VALIDATOR_FACTORY = createDefaultJwtValidatorFactory( + Collections.emptyList()); private static final JOSEObjectTypeVerifier DPOP_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>( new JOSEObjectType("dpop+jwt")); @@ -94,6 +98,73 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory> createDefaultJwtValidatorFactory( + List> validators) { + Assert.notNull(validators, "validators cannot be null"); + List> customValidators = new ArrayList<>(); + if (!CollectionUtils.isEmpty(validators)) { + customValidators.addAll(validators); + } + final Duration clockSkew = Duration.ofSeconds(30); + final JwtIssuedAtValidator jwtIssuedAtValidator; + JwtIssuedAtValidator existingJwtIssuedAtValidator = CollectionUtils.findValueOfType(customValidators, + JwtIssuedAtValidator.class); + if (existingJwtIssuedAtValidator != null) { + jwtIssuedAtValidator = existingJwtIssuedAtValidator; + customValidators.remove(jwtIssuedAtValidator); + } + else { + jwtIssuedAtValidator = new JwtIssuedAtValidator(true); + jwtIssuedAtValidator.setClockSkew(clockSkew); + } + final DPoPProofReplayValidator dPoPProofReplayValidator; + DPoPProofReplayValidator existingDPoPProofReplayValidator = CollectionUtils.findValueOfType(customValidators, + DPoPProofReplayValidator.class); + if (existingDPoPProofReplayValidator != null) { + dPoPProofReplayValidator = existingDPoPProofReplayValidator; + customValidators.remove(dPoPProofReplayValidator); + } + else { + dPoPProofReplayValidator = new DPoPProofReplayValidator(new DPoPProofReplayValidator.InMemoryCache()); + dPoPProofReplayValidator.setClockSkew(clockSkew); + } + return (context) -> createDefaultJwtValidatorFactory(context, jwtIssuedAtValidator, dPoPProofReplayValidator, + customValidators); + } + + private static OAuth2TokenValidator createDefaultJwtValidatorFactory(DPoPProofContext context, + JwtIssuedAtValidator jwtIssuedAtValidator, DPoPProofReplayValidator dPoPProofReplayValidator, + List> customValidators) { + // Add custom validators first then default validators in a specific order + List> tokenValidators = new ArrayList<>(); + if (!CollectionUtils.isEmpty(customValidators)) { + tokenValidators.addAll(customValidators); + } + tokenValidators.add(new JwtClaimValidator<>("htm", context.getMethod()::equalsIgnoreCase)); + tokenValidators.add(new JwtClaimValidator<>("htu", context.getTargetUri()::equals)); + tokenValidators.add(jwtIssuedAtValidator); + if (context.getAccessToken() != null) { + tokenValidators.add(new JwkThumbprintValidator(context.getAccessToken())); + tokenValidators.add(new AthClaimValidator(context.getAccessToken())); + } + tokenValidators.add(dPoPProofReplayValidator); + DelegatingOAuth2TokenValidator delegatingTokenValidator = new DelegatingOAuth2TokenValidator<>( + tokenValidators); + delegatingTokenValidator.setFailOnError(true); + return delegatingTokenValidator; + } + private static NimbusJwtDecoder buildDecoder() { ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); jwtProcessor.setJWSTypeVerifier(DPOP_TYPE_VERIFIER); @@ -137,39 +208,34 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory> defaultJwtValidatorFactory() { - return (context) -> new DelegatingOAuth2TokenValidator<>( - new JwtClaimValidator<>("htm", context.getMethod()::equals), - new JwtClaimValidator<>("htu", context.getTargetUri()::equals), new JtiClaimValidator(), - new JwtIssuedAtValidator(true)); - } + private static final class AthClaimValidator implements OAuth2TokenValidator { - private static final class JtiClaimValidator implements OAuth2TokenValidator { + private final OAuth2Token accessToken; - private static final Map JTI_CACHE = Collections.synchronizedMap(new JtiCache()); + private AthClaimValidator(OAuth2Token accessToken) { + Assert.notNull(accessToken, "accessToken cannot be null"); + this.accessToken = accessToken; + } @Override public OAuth2TokenValidatorResult validate(Jwt jwt) { Assert.notNull(jwt, "DPoP proof jwt cannot be null"); - String jti = jwt.getId(); - if (!StringUtils.hasText(jti)) { - OAuth2Error error = createOAuth2Error("jti claim is required."); + String accessTokenHashClaim = jwt.getClaimAsString("ath"); + if (!StringUtils.hasText(accessTokenHashClaim)) { + OAuth2Error error = createOAuth2Error("ath claim is required."); return OAuth2TokenValidatorResult.failure(error); } - // Enforce single-use to protect against DPoP proof replay - String jtiHash; + String accessTokenHash; try { - jtiHash = computeSHA256(jti); + accessTokenHash = computeSHA256(this.accessToken.getTokenValue()); } catch (Exception ex) { - OAuth2Error error = createOAuth2Error("jti claim is invalid."); + OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token."); return OAuth2TokenValidatorResult.failure(error); } - Instant expiry = Instant.now().plus(1, ChronoUnit.HOURS); - if ((JTI_CACHE.putIfAbsent(jtiHash, expiry.toEpochMilli())) != null) { - // Already used - OAuth2Error error = createOAuth2Error("jti claim is invalid."); + if (!accessTokenHashClaim.equals(accessTokenHash)) { + OAuth2Error error = createOAuth2Error("ath claim is invalid."); return OAuth2TokenValidatorResult.failure(error); } return OAuth2TokenValidatorResult.success(); @@ -185,20 +251,65 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory { + } - private static final int MAX_SIZE = 1000; + private static final class JwkThumbprintValidator implements OAuth2TokenValidator { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - if (size() > MAX_SIZE) { - return true; - } - Instant expiry = Instant.ofEpochMilli(eldest.getValue()); - return Instant.now().isAfter(expiry); + private final OAuth2Token accessToken; + + private final ClaimAccessor claims; + + private JwkThumbprintValidator(OAuth2Token accessToken) { + Assert.notNull(accessToken, "accessToken cannot be null"); + Assert.isInstanceOf(ClaimAccessor.class, accessToken, "accessToken must be instance of ClaimAccessor"); + this.accessToken = accessToken; + this.claims = (ClaimAccessor) accessToken; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + Assert.notNull(jwt, "DPoP proof jwt cannot be null"); + String jwkThumbprintClaim = null; + Map confirmationMethodClaim = this.claims.getClaimAsMap("cnf"); + if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) { + jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt"); + } + if (jwkThumbprintClaim == null) { + OAuth2Error error = createOAuth2Error("jkt claim is required."); + return OAuth2TokenValidatorResult.failure(error); } + JWK jwk = null; + @SuppressWarnings("unchecked") + Map jwkJson = (Map) jwt.getHeaders().get("jwk"); + try { + jwk = JWK.parse(jwkJson); + } + catch (Exception ignored) { + } + if (jwk == null) { + OAuth2Error error = createOAuth2Error("jwk header is missing or invalid."); + return OAuth2TokenValidatorResult.failure(error); + } + + String jwkThumbprint; + try { + jwkThumbprint = jwk.computeThumbprint().toString(); + } + catch (Exception ex) { + OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk."); + return OAuth2TokenValidatorResult.failure(error); + } + + if (!jwkThumbprintClaim.equals(jwkThumbprint)) { + OAuth2Error error = createOAuth2Error("jkt claim is invalid."); + return OAuth2TokenValidatorResult.failure(error); + } + return OAuth2TokenValidatorResult.success(); + } + + private static OAuth2Error createOAuth2Error(String reason) { + return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null); } } diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofReplayValidator.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofReplayValidator.java new file mode 100644 index 0000000000..ca13866e56 --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofReplayValidator.java @@ -0,0 +1,447 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.jwt; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.nimbusds.jose.jwk.JWK; + +import org.springframework.cache.Cache; +import org.springframework.cache.support.SimpleValueWrapper; +import org.springframework.lang.Nullable; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * An {@link OAuth2TokenValidator} that mitigates DPoP Proof Replay. + * + *

+ * This validator mitigates DPoP Proof Replay by ensuring the DPoP Proof: + *

    + *
  • contains the {@code iat} (issued at) claim, and it's within an acceptable time + * window (configured via {@link #setClockSkew(Duration)})
  • + *
  • contains the {@code jti} (JWT ID) claim, and it has not been used previously
  • + *
+ * + *

+ * This implementation uses a {@link Cache} to store the {@code jti} claim (along with + * other information in {@link CacheValue CacheValue}) to enforce single-use. The + * {@code jti} is retained in the cache until the DPoP Proof expires, which is calculated + * as {@code iat + clockSkew}. + * + * @author Joe Grandja + * @since 6.5.12 + * @see OAuth2TokenValidator + * @see DPoPProofJwtDecoderFactory + * @see Section 11.1. DPoP Proof + * Replay + */ +public final class DPoPProofReplayValidator implements OAuth2TokenValidator { + + private final Cache cache; + + private Duration clockSkew = Duration.ofSeconds(30); + + private Clock clock = Clock.systemUTC(); + + /** + * Constructs a {@code DPoPProofReplayValidator} using the provided parameters. + * @param cache the {@link Cache} used to store {@link CacheValue} which contains + * information of the used DPoP Proof {@link Jwt}'s + */ + public DPoPProofReplayValidator(Cache cache) { + Assert.notNull(cache, "cache cannot be null"); + this.cache = cache; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + Assert.notNull(jwt, "DPoP proof jwt cannot be null"); + String jti = jwt.getId(); + if (!StringUtils.hasText(jti)) { + OAuth2Error error = createOAuth2Error("jti claim is required."); + return OAuth2TokenValidatorResult.failure(error); + } + + Instant issuedAt = jwt.getIssuedAt(); + if (issuedAt == null) { + OAuth2Error error = createOAuth2Error("iat claim is required."); + return OAuth2TokenValidatorResult.failure(error); + } + + // Ensure acceptable time window + Instant now = Instant.now(this.clock); + Instant notBefore = now.minus(this.clockSkew); + Instant notAfter = now.plus(this.clockSkew); + if (issuedAt.isBefore(notBefore) || issuedAt.isAfter(notAfter)) { + OAuth2Error error = createOAuth2Error("iat claim is invalid."); + return OAuth2TokenValidatorResult.failure(error); + } + + String jwkThumbprint; + try { + @SuppressWarnings("unchecked") + Map jwkJson = (Map) jwt.getHeaders().get("jwk"); + JWK jwk = JWK.parse(jwkJson); + jwkThumbprint = jwk.computeThumbprint().toString(); + } + catch (Exception ex) { + OAuth2Error error = createOAuth2Error("jwk header is missing or invalid."); + return OAuth2TokenValidatorResult.failure(error); + } + + String jtiHash; + try { + jtiHash = computeSHA256(jti); + } + catch (Exception ex) { + OAuth2Error error = createOAuth2Error("jti claim is invalid."); + return OAuth2TokenValidatorResult.failure(error); + } + + Instant expiresAt = issuedAt.plus(this.clockSkew); + CacheValue cacheValue = new CacheValue(issuedAt, expiresAt, jwkThumbprint); + + // Enforce single-use to protect against DPoP proof replay + if (this.cache.putIfAbsent(jtiHash, cacheValue) != null) { + // Already used or cache full or key limit reached + OAuth2Error error = createOAuth2Error("jti claim is invalid or unable to cache."); + return OAuth2TokenValidatorResult.failure(error); + } + return OAuth2TokenValidatorResult.success(); + } + + /** + * Sets the clock skew. The default is 30 seconds. + * @param clockSkew the clock skew + */ + public void setClockSkew(Duration clockSkew) { + Assert.notNull(clockSkew, "clockSkew cannot be null"); + Assert.isTrue(clockSkew.getSeconds() >= 0, "clockSkew must be >= 0"); + this.clockSkew = clockSkew; + } + + /** + * Sets the {@link Clock} used in {@link Instant#now(Clock)}. + * @param clock the clock + */ + public void setClock(Clock clock) { + Assert.notNull(clock, "clock cannot be null"); + this.clock = clock; + } + + private static OAuth2Error createOAuth2Error(String reason) { + return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null); + } + + private static String computeSHA256(String value) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } + + /** + * An in-memory {@link Cache} implementation backed by a {@link ConcurrentHashMap}. + * + *

+ * NOTE: This implementation has limitations as it only works in a single-node + * setup. For production (and clustered) environments, it is recommended to use a + * distributed {@link Cache} implementation (e.g. Redis, Hazelcast, etc.). + * + *

+ * This implementation can be fine-tuned based on the following configuration + * settings: + *

    + *
  • {@link #setMaxSize(int)} - Sets the maximum number of entries the cache can + * hold. The default is 100,000.
  • + *
  • {@link #setMaxRequestsPerKey(int)} - Sets the maximum number of requests + * allowed per {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is + * 1000.
  • + *
+ */ + public static final class InMemoryCache implements Cache { + + private static final String DEFAULT_NAME = InMemoryCache.class.getName().concat(".DPOP-PROOF-CACHE"); + + private static final int DEFAULT_MAX_SIZE = 100_000; + + private static final int DEFAULT_MAX_REQUESTS_PER_KEY = 1000; + + private static final int CLEANUP_INTERVAL_SECS = 10; + + private final ConcurrentMap cache = new ConcurrentHashMap<>(); + + private final ConcurrentMap requestsPerKey = new ConcurrentHashMap<>(); + + private final AtomicBoolean cleaning = new AtomicBoolean(false); + + private long lastCleanup = System.currentTimeMillis(); + + private int maxSize = DEFAULT_MAX_SIZE; + + private int maxRequestsPerKey = DEFAULT_MAX_REQUESTS_PER_KEY; + + /** + * Returns the maximum number of entries the cache can hold. + * @return the maximum number of entries the cache can hold + */ + public int getMaxSize() { + return this.maxSize; + } + + /** + * Sets the maximum number of entries the cache can hold. The default is 100,000. + * @param maxSize the maximum number of entries the cache can hold + */ + public void setMaxSize(int maxSize) { + Assert.isTrue(maxSize > 0, "maxSize must be > 0"); + this.maxSize = maxSize; + } + + /** + * Returns the maximum number of requests allowed per + * {@link CacheValue#getJwkThumbprint() JWK thumbprint}. + * @return the maximum number of requests allowed per + * {@link CacheValue#getJwkThumbprint() JWK thumbprint} + */ + public int getMaxRequestsPerKey() { + return this.maxRequestsPerKey; + } + + /** + * Sets the maximum number of requests allowed per + * {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is 1000. + * @param maxRequestsPerKey the maximum number of requests allowed per + * {@link CacheValue#getJwkThumbprint() JWK thumbprint} + */ + public void setMaxRequestsPerKey(int maxRequestsPerKey) { + Assert.isTrue(maxRequestsPerKey > 0, "maxRequestsPerKey must be > 0"); + this.maxRequestsPerKey = maxRequestsPerKey; + } + + @Override + public String getName() { + return DEFAULT_NAME; + } + + @Override + public Object getNativeCache() { + return this.cache; + } + + @Override + public @Nullable ValueWrapper get(Object key) { + Object value = this.cache.get(key); + return (value != null) ? new SimpleValueWrapper(value) : null; + } + + @SuppressWarnings("unchecked") + @Override + public @Nullable T get(Object key, @Nullable Class type) { + Object value = this.cache.get(key); + if (value != null && type != null && !type.isInstance(value)) { + throw new IllegalStateException( + "Cached value is not of required type [" + type.getName() + "]: " + value); + } + return (T) value; + } + + @Override + public @Nullable T get(Object key, Callable valueLoader) { + throw new UnsupportedOperationException(); + } + + @Override + public void put(Object key, @Nullable Object value) { + putIfAbsent(key, value); + } + + @Override + public @Nullable ValueWrapper putIfAbsent(Object key, @Nullable Object value) { + Assert.notNull(value, "value cannot be null"); + String jti = (String) key; + CacheValue cacheValue = (CacheValue) value; + + cleanupIfNecessary(); + if (this.cache.size() >= this.maxSize) { + // Force an immediate cleanup when we hit the limit before the cleanup + // interval + cleanup(); + if (this.cache.size() >= this.maxSize) { + // Cache full - return non-null value + return new SimpleValueWrapper(cacheValue); + } + } + + // Limit the number of requests per key + AtomicBoolean limitExceeded = new AtomicBoolean(false); + this.requestsPerKey.compute(cacheValue.jwkThumbprint, (k, v) -> { + if (v != null && v >= this.maxRequestsPerKey) { + limitExceeded.set(true); + return v; + } + // Increment + return (v != null) ? v + 1 : 1; + }); + if (limitExceeded.get()) { + // Key limit reached - return non-null value + return new SimpleValueWrapper(cacheValue); + } + + if (this.cache.putIfAbsent(jti, cacheValue) != null) { + // jti exists - revert the increment and return non-null value + this.requestsPerKey.computeIfPresent(cacheValue.jwkThumbprint, (k, v) -> (v > 1) ? v - 1 : null); + return new SimpleValueWrapper(cacheValue); + } + + return null; + } + + @Override + public void evict(Object key) { + } + + @Override + public void clear() { + } + + private void cleanupIfNecessary() { + long now = System.currentTimeMillis(); + long last = this.lastCleanup; + if ((now - last) > (CLEANUP_INTERVAL_SECS * 1000)) { + cleanup(); + } + } + + private void cleanup() { + if (this.cleaning.compareAndSet(false, true)) { + try { + Instant now = Instant.now(); + for (Map.Entry entry : this.cache.entrySet()) { + if (now.isAfter(entry.getValue().expiresAt)) { + this.cache.remove(entry.getKey()); + this.requestsPerKey.computeIfPresent(entry.getValue().jwkThumbprint, + (k, v) -> (v > 1) ? v - 1 : null); + } + } + this.lastCleanup = System.currentTimeMillis(); + } + finally { + this.cleaning.set(false); + } + } + } + + } + + /** + * A representation of the value to which the {@link Cache} maps a (hashed) + * {@code (jti)} claim as the key. + */ + public static final class CacheValue { + + private final Instant issuedAt; + + private final Instant expiresAt; + + private final String jwkThumbprint; + + /** + * Constructs a {@code CacheValue} using the provided parameters. + * @param issuedAt the issued at claim which identifies the time at which the DPoP + * Proof {@link Jwt} was issued + * @param expiresAt the expiration time when this {@code CacheValue} will be + * evicted from the cache + * @param jwkThumbprint the SHA-256 thumbprint of the public key of the JSON Web + * Key (JWK) corresponding to the key used to digitally sign the DPoP Proof + * {@link Jwt} + */ + public CacheValue(Instant issuedAt, Instant expiresAt, String jwkThumbprint) { + Assert.notNull(issuedAt, "issuedAt cannot be null"); + Assert.notNull(expiresAt, "expiresAt cannot be null"); + Assert.hasText(jwkThumbprint, "jwkThumbprint cannot be empty"); + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + this.jwkThumbprint = jwkThumbprint; + } + + /** + * Returns the issued at {@code (iat)} claim which identifies the time at which + * the DPoP Proof {@link Jwt} was issued. + * @return the issued at claim which identifies the time at which the DPoP Proof + * {@link Jwt} was issued + */ + public Instant getIssuedAt() { + return this.issuedAt; + } + + /** + * Returns the expiration time when this {@code CacheValue} will be evicted from + * the cache. + * @return the expiration time when this {@code CacheValue} will be evicted from + * the cache + */ + public Instant getExpiresAt() { + return this.expiresAt; + } + + /** + * Returns the SHA-256 thumbprint of the public key of the JSON Web Key (JWK) + * corresponding to the key used to digitally sign the DPoP Proof {@link Jwt}. + * @return the SHA-256 thumbprint of the public key of the JSON Web Key (JWK) + */ + public String getJwkThumbprint() { + return this.jwkThumbprint; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null || obj.getClass() != this.getClass()) { + return false; + } + CacheValue that = (CacheValue) obj; + return Objects.equals(this.issuedAt, that.issuedAt) && Objects.equals(this.expiresAt, that.expiresAt) + && Objects.equals(this.jwkThumbprint, that.jwkThumbprint); + } + + @Override + public int hashCode() { + return Objects.hash(this.issuedAt, this.expiresAt, this.jwkThumbprint); + } + + } + +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/DPoPAuthenticationProvider.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/DPoPAuthenticationProvider.java index 361dec2113..ba0a19e4fb 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/DPoPAuthenticationProvider.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/DPoPAuthenticationProvider.java @@ -16,14 +16,9 @@ package org.springframework.security.oauth2.server.resource.authentication; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; import java.time.Instant; -import java.util.Base64; import java.util.Map; -import java.util.function.Function; -import com.nimbusds.jose.jwk.JWK; import org.jspecify.annotations.Nullable; import org.springframework.security.authentication.AuthenticationManager; @@ -31,22 +26,16 @@ import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.oauth2.core.ClaimAccessor; -import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.OAuth2Token; -import org.springframework.security.oauth2.core.OAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.security.oauth2.jwt.DPoPProofContext; import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtDecoderFactory; -import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.util.Assert; -import org.springframework.util.CollectionUtils; -import org.springframework.util.StringUtils; /** * An {@link AuthenticationProvider} implementation that is responsible for authenticating @@ -73,18 +62,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider public DPoPAuthenticationProvider(AuthenticationManager tokenAuthenticationManager) { Assert.notNull(tokenAuthenticationManager, "tokenAuthenticationManager cannot be null"); this.tokenAuthenticationManager = tokenAuthenticationManager; - Function> jwtValidatorFactory = (context) -> { - OAuth2AccessTokenClaims accessToken = context.getAccessToken(); - Assert.notNull(accessToken, "accessToken cannot be null"); - return new DelegatingOAuth2TokenValidator<>( - // Use default validators - DPoPProofJwtDecoderFactory.DEFAULT_JWT_VALIDATOR_FACTORY.apply(context), - // Add custom validators - new AthClaimValidator(accessToken), new JwkThumbprintValidator(accessToken)); - }; - DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory(); - dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory); - this.dPoPProofVerifierFactory = dPoPProofJwtDecoderFactory; + this.dPoPProofVerifierFactory = new DPoPProofJwtDecoderFactory(); } @Override @@ -119,7 +97,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider try { dPoPProofVerifier.decode(dPoPProofContext.getDPoPProof()); } - catch (JwtException ex) { + catch (Exception ex) { OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF); throw new OAuth2AuthenticationException(error, ex); } @@ -144,108 +122,6 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider this.dPoPProofVerifierFactory = dPoPProofVerifierFactory; } - private static final class AthClaimValidator implements OAuth2TokenValidator { - - private final OAuth2AccessTokenClaims accessToken; - - private AthClaimValidator(OAuth2AccessTokenClaims accessToken) { - Assert.notNull(accessToken, "accessToken cannot be null"); - this.accessToken = accessToken; - } - - @Override - public OAuth2TokenValidatorResult validate(Jwt jwt) { - Assert.notNull(jwt, "DPoP proof jwt cannot be null"); - String accessTokenHashClaim = jwt.getClaimAsString("ath"); - if (!StringUtils.hasText(accessTokenHashClaim)) { - OAuth2Error error = createOAuth2Error("ath claim is required."); - return OAuth2TokenValidatorResult.failure(error); - } - - String accessTokenHash; - try { - accessTokenHash = computeSHA256(this.accessToken.getTokenValue()); - } - catch (Exception ex) { - OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token."); - return OAuth2TokenValidatorResult.failure(error); - } - if (!accessTokenHashClaim.equals(accessTokenHash)) { - OAuth2Error error = createOAuth2Error("ath claim is invalid."); - return OAuth2TokenValidatorResult.failure(error); - } - return OAuth2TokenValidatorResult.success(); - } - - private static OAuth2Error createOAuth2Error(String reason) { - return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null); - } - - private static String computeSHA256(String value) throws Exception { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8)); - return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); - } - - } - - private static final class JwkThumbprintValidator implements OAuth2TokenValidator { - - private final OAuth2AccessTokenClaims accessToken; - - private JwkThumbprintValidator(OAuth2AccessTokenClaims accessToken) { - Assert.notNull(accessToken, "accessToken cannot be null"); - this.accessToken = accessToken; - } - - @Override - public OAuth2TokenValidatorResult validate(Jwt jwt) { - Assert.notNull(jwt, "DPoP proof jwt cannot be null"); - String jwkThumbprintClaim = null; - Map confirmationMethodClaim = this.accessToken.getClaimAsMap("cnf"); - if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) { - jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt"); - } - if (jwkThumbprintClaim == null) { - OAuth2Error error = createOAuth2Error("jkt claim is required."); - return OAuth2TokenValidatorResult.failure(error); - } - - JWK jwk = null; - @SuppressWarnings("unchecked") - Map jwkJson = (Map) jwt.getHeaders().get("jwk"); - try { - jwk = JWK.parse(jwkJson); - } - catch (Exception ignored) { - } - if (jwk == null) { - OAuth2Error error = createOAuth2Error("jwk header is missing or invalid."); - return OAuth2TokenValidatorResult.failure(error); - } - - String jwkThumbprint; - try { - jwkThumbprint = jwk.computeThumbprint().toString(); - } - catch (Exception ex) { - OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk."); - return OAuth2TokenValidatorResult.failure(error); - } - - if (!jwkThumbprintClaim.equals(jwkThumbprint)) { - OAuth2Error error = createOAuth2Error("jkt claim is invalid."); - return OAuth2TokenValidatorResult.failure(error); - } - return OAuth2TokenValidatorResult.success(); - } - - private static OAuth2Error createOAuth2Error(String reason) { - return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null); - } - - } - private static final class OAuth2AccessTokenClaims implements OAuth2Token, ClaimAccessor { private final OAuth2Token accessToken;