Provide ability to configure DPoP proof replay
This commit is contained in:
committed by
Josh Cummings
parent
deddcce169
commit
b47b96e6b4
+3
-1
@@ -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());
|
||||
|
||||
+29
-2
@@ -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
|
||||
|
||||
+14
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+146
-35
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+447
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+2
-126
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user