1
0
mirror of synced 2026-08-23 10:37:39 +00:00

Provide ability to configure DPoP proof replay

This commit is contained in:
Joe Grandja
2026-05-07 06:29:43 -04:00
committed by Josh Cummings
parent 61be628ad6
commit 4c41928bc7
6 changed files with 641 additions and 164 deletions
@@ -706,8 +706,10 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
}
private void configure(H http) {
DPoPAuthenticationProvider authenticationProvider = new DPoPAuthenticationProvider(
getTokenAuthenticationManager(http));
http.authenticationProvider(postProcess(authenticationProvider));
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
http.authenticationProvider(new DPoPAuthenticationProvider(getTokenAuthenticationManager(http)));
AuthenticationFilter authenticationFilter = new AuthenticationFilter(authenticationManager,
getAuthenticationConverter());
authenticationFilter.setRequestMatcher(getRequestMatcher());
@@ -22,6 +22,7 @@ import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
@@ -30,6 +31,7 @@ import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jose.jwk.JWK;
@@ -47,20 +49,26 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.ObjectPostProcessor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.jose.TestJwks;
import org.springframework.security.oauth2.jose.TestKeys;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.DPoPProofContext;
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
import org.springframework.security.oauth2.jwt.DPoPProofReplayValidator;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationProvider;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -247,8 +255,8 @@ public class DPoPAuthenticationTests {
)
.oauth2ResourceServer((oauth2) -> 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<DPoPAuthenticationProvider> dPoPProofVerifierFactoryCustomizer() {
return new ObjectPostProcessor<>() {
@Override
public <O extends DPoPAuthenticationProvider> 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<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = DPoPProofJwtDecoderFactory
.createDefaultJwtValidatorFactory(Collections.singletonList(dPoPProofReplayValidator));
DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory();
dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory);
authenticationProvider.setDPoPProofVerifierFactory(dPoPProofJwtDecoderFactory);
return authenticationProvider;
}
};
}
}
@RestController
@@ -33,6 +33,8 @@ public final class DelegatingOAuth2TokenValidator<T extends OAuth2Token> impleme
private final Collection<OAuth2TokenValidator<T>> 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<T extends OAuth2Token> impleme
Collection<OAuth2Error> errors = new ArrayList<>();
for (OAuth2TokenValidator<T> 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;
}
}
@@ -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<DPoPP
/**
* The default {@code OAuth2TokenValidator<Jwt>} 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<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = defaultJwtValidatorFactory();
public static final Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = createDefaultJwtValidatorFactory(
Collections.emptyList());
private static final JOSEObjectTypeVerifier<SecurityContext> DPOP_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>(
new JOSEObjectType("dpop+jwt"));
@@ -94,6 +98,73 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
this.jwtValidatorFactory = jwtValidatorFactory;
}
/**
* Creates a factory that provides an {@link OAuth2TokenValidator} for the specified
* {@link DPoPProofContext} and is used by the {@link JwtDecoder}. The returned
* factory provides a validator that validates the {@code htm}, {@code htu},
* {@code iat}, {@code jkt}, {@code ath} and {@code jti} claims, along with any custom
* validators provided.
* @param validators the custom validators to add
* @return a factory that provides an {@link OAuth2TokenValidator} for the specified
* {@link DPoPProofContext}
* @since 6.5.12
*/
public static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> createDefaultJwtValidatorFactory(
List<OAuth2TokenValidator<Jwt>> validators) {
Assert.notNull(validators, "validators cannot be null");
List<OAuth2TokenValidator<Jwt>> 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<Jwt> createDefaultJwtValidatorFactory(DPoPProofContext context,
JwtIssuedAtValidator jwtIssuedAtValidator, DPoPProofReplayValidator dPoPProofReplayValidator,
List<OAuth2TokenValidator<Jwt>> customValidators) {
// Add custom validators first then default validators in a specific order
List<OAuth2TokenValidator<Jwt>> 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<Jwt> delegatingTokenValidator = new DelegatingOAuth2TokenValidator<>(
tokenValidators);
delegatingTokenValidator.setFailOnError(true);
return delegatingTokenValidator;
}
private static NimbusJwtDecoder buildDecoder() {
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
jwtProcessor.setJWSTypeVerifier(DPOP_TYPE_VERIFIER);
@@ -137,39 +208,34 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
};
}
private static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> 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<Jwt> {
private static final class JtiClaimValidator implements OAuth2TokenValidator<Jwt> {
private final OAuth2Token accessToken;
private static final Map<String, Long> 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<DPoPP
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
@SuppressWarnings("serial")
private static final class JtiCache extends LinkedHashMap<String, Long> {
}
private static final int MAX_SIZE = 1000;
private static final class JwkThumbprintValidator implements OAuth2TokenValidator<Jwt> {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Long> 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<String, Object> 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<String, Object> jwkJson = (Map<String, Object>) 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);
}
}
@@ -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.
*
* <p>
* This validator mitigates DPoP Proof Replay by ensuring the DPoP Proof:
* <ul>
* <li>contains the {@code iat} (issued at) claim, and it's within an acceptable time
* window (configured via {@link #setClockSkew(Duration)})</li>
* <li>contains the {@code jti} (JWT ID) claim, and it has not been used previously</li>
* </ul>
*
* <p>
* 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 <a target="_blank" href=
* "https://datatracker.ietf.org/doc/html/rfc9449#section-11.1">Section 11.1. DPoP Proof
* Replay</a>
*/
public final class DPoPProofReplayValidator implements OAuth2TokenValidator<Jwt> {
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<String, Object> jwkJson = (Map<String, Object>) 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}.
*
* <p>
* <b>NOTE:</b> 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.).
*
* <p>
* This implementation can be fine-tuned based on the following configuration
* settings:
* <ul>
* <li>{@link #setMaxSize(int)} - Sets the maximum number of entries the cache can
* hold. The default is 100,000.</li>
* <li>{@link #setMaxRequestsPerKey(int)} - Sets the maximum number of requests
* allowed per {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is
* 1000.</li>
* </ul>
*/
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<String, CacheValue> cache = new ConcurrentHashMap<>();
private final ConcurrentMap<String, Integer> 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 <T> @Nullable T get(Object key, @Nullable Class<T> 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 <T> @Nullable T get(Object key, Callable<T> 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<String, CacheValue> 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);
}
}
}
@@ -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<DPoPProofContext, OAuth2TokenValidator<Jwt>> 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<Jwt> {
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<Jwt> {
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<String, Object> 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<String, Object> jwkJson = (Map<String, Object>) 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;