diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurer.java
index cee89e04271..fcef31b191b 100644
--- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurer.java
+++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurer.java
@@ -16,32 +16,13 @@
package org.springframework.security.config.annotation.web.configurers.oauth2.server.resource;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.AuthenticationException;
-import org.springframework.security.oauth2.core.OAuth2AccessToken;
-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.endpoint.OAuth2ParameterNames;
-import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
+import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationProvider;
-import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationToken;
-import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.oauth2.server.resource.web.DPoPAuthenticationEntryPoint;
+import org.springframework.security.oauth2.server.resource.web.DPoPRequestMatcher;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.security.web.authentication.AuthenticationEntryPointFailureHandler;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
@@ -49,20 +30,20 @@
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
import org.springframework.security.web.util.matcher.RequestMatcher;
-import org.springframework.util.CollectionUtils;
-import org.springframework.util.StringUtils;
+import org.springframework.util.Assert;
/**
* An {@link AbstractHttpConfigurer} for OAuth 2.0 Demonstrating Proof of Possession
* (DPoP) support.
*
* @author Joe Grandja
+ * @author Max Batischev
* @since 6.5
* @see DPoPAuthenticationProvider
* @see RFC 9449
* OAuth 2.0 Demonstrating Proof of Possession (DPoP)
*/
-final class DPoPAuthenticationConfigurer>
+public final class DPoPAuthenticationConfigurer>
extends AbstractHttpConfigurer, B> {
private RequestMatcher requestMatcher;
@@ -87,6 +68,50 @@ public void configure(B http) {
http.addFilter(authenticationFilter);
}
+ /**
+ * Sets the {@link RequestMatcher} to use.
+ * @param requestMatcher
+ * @since 7.0
+ */
+ public DPoPAuthenticationConfigurer requestMatcher(RequestMatcher requestMatcher) {
+ Assert.notNull(requestMatcher, "requestMatcher cannot be null");
+ this.requestMatcher = requestMatcher;
+ return this;
+ }
+
+ /**
+ * Sets the {@link AuthenticationConverter} to use.
+ * @param authenticationConverter
+ * @since 7.0
+ */
+ public DPoPAuthenticationConfigurer authenticationConverter(AuthenticationConverter authenticationConverter) {
+ Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
+ this.authenticationConverter = authenticationConverter;
+ return this;
+ }
+
+ /**
+ * Sets the {@link AuthenticationFailureHandler} to use.
+ * @param failureHandler
+ * @since 7.0
+ */
+ public DPoPAuthenticationConfigurer failureHandler(AuthenticationFailureHandler failureHandler) {
+ Assert.notNull(failureHandler, "failureHandler cannot be null");
+ this.authenticationFailureHandler = failureHandler;
+ return this;
+ }
+
+ /**
+ * Sets the {@link AuthenticationSuccessHandler} to use.
+ * @param successHandler
+ * @since 7.0
+ */
+ public DPoPAuthenticationConfigurer successHandler(AuthenticationSuccessHandler successHandler) {
+ Assert.notNull(successHandler, "successHandler cannot be null");
+ this.authenticationSuccessHandler = successHandler;
+ return this;
+ }
+
private RequestMatcher getRequestMatcher() {
if (this.requestMatcher == null) {
this.requestMatcher = new DPoPRequestMatcher();
@@ -118,101 +143,4 @@ private AuthenticationFailureHandler getAuthenticationFailureHandler() {
return this.authenticationFailureHandler;
}
- private static final class DPoPRequestMatcher implements RequestMatcher {
-
- @Override
- public boolean matches(HttpServletRequest request) {
- String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
- if (!StringUtils.hasText(authorization)) {
- return false;
- }
- return StringUtils.startsWithIgnoreCase(authorization, OAuth2AccessToken.TokenType.DPOP.getValue());
- }
-
- }
-
- private static final class DPoPAuthenticationConverter implements AuthenticationConverter {
-
- private static final Pattern AUTHORIZATION_PATTERN = Pattern.compile("^DPoP (?[a-zA-Z0-9-._~+/]+=*)$",
- Pattern.CASE_INSENSITIVE);
-
- @Override
- public Authentication convert(HttpServletRequest request) {
- List authorizationList = Collections.list(request.getHeaders(HttpHeaders.AUTHORIZATION));
- if (CollectionUtils.isEmpty(authorizationList)) {
- return null;
- }
- if (authorizationList.size() != 1) {
- OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST,
- "Found multiple Authorization headers.", null);
- throw new OAuth2AuthenticationException(error);
- }
- String authorization = authorizationList.get(0);
- if (!StringUtils.startsWithIgnoreCase(authorization, OAuth2AccessToken.TokenType.DPOP.getValue())) {
- return null;
- }
- Matcher matcher = AUTHORIZATION_PATTERN.matcher(authorization);
- if (!matcher.matches()) {
- OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "DPoP access token is malformed.",
- null);
- throw new OAuth2AuthenticationException(error);
- }
- String accessToken = matcher.group("token");
- List dPoPProofList = Collections
- .list(request.getHeaders(OAuth2AccessToken.TokenType.DPOP.getValue()));
- if (CollectionUtils.isEmpty(dPoPProofList) || dPoPProofList.size() != 1) {
- OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST,
- "DPoP proof is missing or invalid.", null);
- throw new OAuth2AuthenticationException(error);
- }
- String dPoPProof = dPoPProofList.get(0);
- return new DPoPAuthenticationToken(accessToken, dPoPProof, request.getMethod(),
- request.getRequestURL().toString());
- }
-
- }
-
- private static final class DPoPAuthenticationEntryPoint implements AuthenticationEntryPoint {
-
- @Override
- public void commence(HttpServletRequest request, HttpServletResponse response,
- AuthenticationException authenticationException) {
- Map parameters = new LinkedHashMap<>();
- if (authenticationException instanceof OAuth2AuthenticationException oauth2AuthenticationException) {
- OAuth2Error error = oauth2AuthenticationException.getError();
- parameters.put(OAuth2ParameterNames.ERROR, error.getErrorCode());
- if (StringUtils.hasText(error.getDescription())) {
- parameters.put(OAuth2ParameterNames.ERROR_DESCRIPTION, error.getDescription());
- }
- if (StringUtils.hasText(error.getUri())) {
- parameters.put(OAuth2ParameterNames.ERROR_URI, error.getUri());
- }
- }
- parameters.put("algs",
- JwsAlgorithms.RS256 + " " + JwsAlgorithms.RS384 + " " + JwsAlgorithms.RS512 + " "
- + JwsAlgorithms.PS256 + " " + JwsAlgorithms.PS384 + " " + JwsAlgorithms.PS512 + " "
- + JwsAlgorithms.ES256 + " " + JwsAlgorithms.ES384 + " " + JwsAlgorithms.ES512);
- String wwwAuthenticate = toWWWAuthenticateHeader(parameters);
- response.addHeader(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate);
- response.setStatus(HttpStatus.UNAUTHORIZED.value());
- }
-
- private static String toWWWAuthenticateHeader(Map parameters) {
- StringBuilder wwwAuthenticate = new StringBuilder();
- wwwAuthenticate.append(OAuth2AccessToken.TokenType.DPOP.getValue());
- if (!parameters.isEmpty()) {
- wwwAuthenticate.append(" ");
- int i = 0;
- for (Map.Entry entry : parameters.entrySet()) {
- wwwAuthenticate.append(entry.getKey()).append("=\"").append(entry.getValue()).append("\"");
- if (i++ != parameters.size() - 1) {
- wwwAuthenticate.append(", ");
- }
- }
- }
- return wwwAuthenticate.toString();
- }
-
- }
-
}
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 e9a425d46d2..054c2902ff6 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
@@ -138,6 +138,7 @@
* @author Josh Cummings
* @author Evgeniy Cheban
* @author Jerome Wacongne <ch4mp@c4-soft.com>
+ * @author Max Batischev
* @since 5.1
* @see BearerTokenAuthenticationFilter
* @see JwtAuthenticationProvider
@@ -152,7 +153,7 @@ public final class OAuth2ResourceServerConfigurer dPoPAuthenticationConfigurer = new DPoPAuthenticationConfigurer<>();
+ private DPoPAuthenticationConfigurer dPoPAuthenticationConfigurer;
private AuthenticationManagerResolver authenticationManagerResolver;
@@ -257,6 +258,22 @@ public OAuth2ResourceServerConfigurer opaqueToken(Customizer dpop(
+ Customizer> dpopAuthenticatioCustomizer) {
+ if (this.dPoPAuthenticationConfigurer == null) {
+ this.dPoPAuthenticationConfigurer = new DPoPAuthenticationConfigurer<>();
+ }
+ dpopAuthenticatioCustomizer.customize(this.dPoPAuthenticationConfigurer);
+ return this;
+ }
+
@Override
public void init(H http) {
validateConfiguration();
@@ -285,7 +302,9 @@ public void configure(H http) {
filter.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy());
filter = postProcess(filter);
http.addFilter(filter);
- this.dPoPAuthenticationConfigurer.configure(http);
+ if (this.dPoPAuthenticationConfigurer != null) {
+ this.dPoPAuthenticationConfigurer.configure(http);
+ }
}
private void validateConfiguration() {
diff --git a/config/src/main/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParser.java
index 4fd3d12948d..ed58110877e 100644
--- a/config/src/main/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParser.java
+++ b/config/src/main/java/org/springframework/security/config/http/OAuth2ResourceServerBeanDefinitionParser.java
@@ -40,7 +40,7 @@
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider;
import org.springframework.security.oauth2.server.resource.authentication.OpaqueTokenAuthenticationProvider;
-import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector;
+import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver;
@@ -125,7 +125,7 @@ public BeanDefinition parse(Element oauth2ResourceServer, ParserContext pc) {
}
BeanMetadataElement bearerTokenResolver = getBearerTokenResolver(oauth2ResourceServer);
BeanDefinitionBuilder requestMatcherBuilder = BeanDefinitionBuilder
- .rootBeanDefinition(BearerTokenRequestMatcher.class);
+ .rootBeanDefinition(BearerTokenRequestMatcher.class);
requestMatcherBuilder.addConstructorArgValue(bearerTokenResolver);
BeanDefinition requestMatcher = requestMatcherBuilder.getBeanDefinition();
BeanMetadataElement authenticationEntryPoint = getEntryPoint(oauth2ResourceServer);
@@ -133,7 +133,7 @@ public BeanDefinition parse(Element oauth2ResourceServer, ParserContext pc) {
this.deniedHandlers.put(requestMatcher, this.accessDeniedHandler);
this.ignoreCsrfRequestMatchers.add(requestMatcher);
BeanDefinitionBuilder filterBuilder = BeanDefinitionBuilder
- .rootBeanDefinition(BearerTokenAuthenticationFilter.class);
+ .rootBeanDefinition(BearerTokenAuthenticationFilter.class);
BeanMetadataElement authenticationManagerResolver = getAuthenticationManagerResolver(oauth2ResourceServer);
filterBuilder.addConstructorArgValue(authenticationManagerResolver);
filterBuilder.addPropertyValue(BEARER_TOKEN_RESOLVER, bearerTokenResolver);
@@ -147,20 +147,20 @@ void validateConfiguration(Element oauth2ResourceServer, Element jwt, Element op
if (!oauth2ResourceServer.hasAttribute(AUTHENTICATION_MANAGER_RESOLVER_REF)) {
if (jwt == null && opaqueToken == null) {
pc.getReaderContext()
- .error("Didn't find authentication-manager-resolver-ref, " + ", or . "
- + "Please select one.", oauth2ResourceServer);
+ .error("Didn't find authentication-manager-resolver-ref, " + ", or . "
+ + "Please select one.", oauth2ResourceServer);
}
return;
}
if (jwt != null) {
pc.getReaderContext()
- .error("Found as well as authentication-manager-resolver-ref. Please select just one.",
- oauth2ResourceServer);
+ .error("Found as well as authentication-manager-resolver-ref. Please select just one.",
+ oauth2ResourceServer);
}
if (opaqueToken != null) {
pc.getReaderContext()
- .error("Found as well as authentication-manager-resolver-ref. Please select just one.",
- oauth2ResourceServer);
+ .error("Found as well as authentication-manager-resolver-ref. Please select just one.",
+ oauth2ResourceServer);
}
}
@@ -170,7 +170,7 @@ BeanMetadataElement getAuthenticationManagerResolver(Element element) {
return new RuntimeBeanReference(authenticationManagerResolverRef);
}
BeanDefinitionBuilder authenticationManagerResolver = BeanDefinitionBuilder
- .rootBeanDefinition(StaticAuthenticationManagerResolver.class);
+ .rootBeanDefinition(StaticAuthenticationManagerResolver.class);
authenticationManagerResolver.addConstructorArgValue(this.authenticationManager);
return authenticationManagerResolver.getBeanDefinition();
}
@@ -208,7 +208,7 @@ static final class JwtBeanDefinitionParser implements BeanDefinitionParser {
public BeanDefinition parse(Element element, ParserContext pc) {
validateConfiguration(element, pc);
BeanDefinitionBuilder jwtProviderBuilder = BeanDefinitionBuilder
- .rootBeanDefinition(JwtAuthenticationProvider.class);
+ .rootBeanDefinition(JwtAuthenticationProvider.class);
jwtProviderBuilder.addConstructorArgValue(getDecoder(element));
jwtProviderBuilder.addPropertyValue(JWT_AUTHENTICATION_CONVERTER, getJwtAuthenticationConverter(element));
return jwtProviderBuilder.getBeanDefinition();
@@ -228,7 +228,7 @@ Object getDecoder(Element element) {
return new RuntimeBeanReference(decoderRef);
}
BeanDefinitionBuilder builder = BeanDefinitionBuilder
- .rootBeanDefinition(NimbusJwtDecoderJwkSetUriFactoryBean.class);
+ .rootBeanDefinition(NimbusJwtDecoderJwkSetUriFactoryBean.class);
builder.addConstructorArgValue(element.getAttribute(JWK_SET_URI));
return builder.getBeanDefinition();
}
@@ -264,7 +264,7 @@ public BeanDefinition parse(Element element, ParserContext pc) {
BeanMetadataElement introspector = getIntrospector(element);
String authenticationConverterRef = element.getAttribute(AUTHENTICATION_CONVERTER_REF);
BeanDefinitionBuilder opaqueTokenProviderBuilder = BeanDefinitionBuilder
- .rootBeanDefinition(OpaqueTokenAuthenticationProvider.class);
+ .rootBeanDefinition(OpaqueTokenAuthenticationProvider.class);
opaqueTokenProviderBuilder.addConstructorArgValue(introspector);
if (StringUtils.hasText(authenticationConverterRef)) {
opaqueTokenProviderBuilder.addPropertyReference(AUTHENTICATION_CONVERTER, authenticationConverterRef);
@@ -278,15 +278,15 @@ void validateConfiguration(Element element, ParserContext pc) {
|| element.hasAttribute(CLIENT_SECRET);
if (usesIntrospector == usesEndpoint) {
pc.getReaderContext()
- .error("Please specify either introspector-ref or all of "
- + "introspection-uri, client-id, and client-secret.", element);
+ .error("Please specify either introspector-ref or all of "
+ + "introspection-uri, client-id, and client-secret.", element);
return;
}
if (usesEndpoint) {
if (!(element.hasAttribute(INTROSPECTION_URI) && element.hasAttribute(CLIENT_ID)
&& element.hasAttribute(CLIENT_SECRET))) {
pc.getReaderContext()
- .error("Please specify introspection-uri, client-id, and client-secret together", element);
+ .error("Please specify introspection-uri, client-id, and client-secret together", element);
}
}
}
@@ -300,7 +300,7 @@ BeanMetadataElement getIntrospector(Element element) {
String clientId = element.getAttribute(CLIENT_ID);
String clientSecret = element.getAttribute(CLIENT_SECRET);
BeanDefinitionBuilder introspectorBuilder = BeanDefinitionBuilder
- .rootBeanDefinition(SpringOpaqueTokenIntrospector.class);
+ .rootBeanDefinition(NimbusOpaqueTokenIntrospector.class);
introspectorBuilder.addConstructorArgValue(introspectionUri);
introspectorBuilder.addConstructorArgValue(clientId);
introspectorBuilder.addConstructorArgValue(clientSecret);
diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OAuth2ResourceServerDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OAuth2ResourceServerDsl.kt
index 02f4e7739f5..0126d359b80 100644
--- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OAuth2ResourceServerDsl.kt
+++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OAuth2ResourceServerDsl.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2025 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.
@@ -25,12 +25,15 @@ import org.springframework.security.oauth2.server.resource.web.BearerTokenResolv
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.access.AccessDeniedHandler
import jakarta.servlet.http.HttpServletRequest
+import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.DPoPAuthenticationConfigurer
+import org.springframework.security.config.annotation.web.oauth2.resourceserver.DPoPDsl
/**
* A Kotlin DSL to configure [HttpSecurity] OAuth 2.0 resource server support using
* idiomatic Kotlin code.
*
* @author Eleftheria Stein
+ * @author Max Batischev
* @since 5.3
* @property accessDeniedHandler the [AccessDeniedHandler] to use for requests authenticating
* with Bearer Tokens.
@@ -48,6 +51,7 @@ class OAuth2ResourceServerDsl {
private var jwt: ((OAuth2ResourceServerConfigurer.JwtConfigurer) -> Unit)? = null
private var opaqueToken: ((OAuth2ResourceServerConfigurer.OpaqueTokenConfigurer) -> Unit)? = null
+ private var dpop: ((DPoPAuthenticationConfigurer) -> Unit)? = null
/**
* Enables JWT-encoded bearer token support.
@@ -109,6 +113,36 @@ class OAuth2ResourceServerDsl {
this.opaqueToken = OpaqueTokenDsl().apply(opaqueTokenConfig).get()
}
+ /**
+ * Enables DPoP support.
+ *
+ * Example:
+ *
+ * ```
+ * @Configuration
+ * @EnableWebSecurity
+ * class SecurityConfig {
+ *
+ * @Bean
+ * fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
+ * http {
+ * oauth2ResourceServer {
+ * dpop { }
+ * }
+ * }
+ * return http.build()
+ * }
+ * }
+ * ```
+ *
+ * @param dpopConfig custom configurations to configure DPoP support
+ * @see [DPoPDsl]
+ * @since 7.0
+ */
+ fun dpop(dpopConfig: DPoPDsl.() -> Unit) {
+ this.dpop = DPoPDsl().apply(dpopConfig).get()
+ }
+
internal fun get(): (OAuth2ResourceServerConfigurer) -> Unit {
return { oauth2ResourceServer ->
accessDeniedHandler?.also { oauth2ResourceServer.accessDeniedHandler(accessDeniedHandler) }
@@ -117,6 +151,7 @@ class OAuth2ResourceServerDsl {
authenticationManagerResolver?.also { oauth2ResourceServer.authenticationManagerResolver(authenticationManagerResolver) }
jwt?.also { oauth2ResourceServer.jwt(jwt) }
opaqueToken?.also { oauth2ResourceServer.opaqueToken(opaqueToken) }
+ dpop?.also { oauth2ResourceServer.dpop(dpop) }
}
}
}
diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/DPoPDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/DPoPDsl.kt
new file mode 100644
index 00000000000..a31d362abcf
--- /dev/null
+++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/DPoPDsl.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2002-2025 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.config.annotation.web.oauth2.resourceserver
+
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.DPoPAuthenticationConfigurer
+import org.springframework.security.web.authentication.AuthenticationConverter
+import org.springframework.security.web.authentication.AuthenticationFailureHandler
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler
+import org.springframework.security.web.util.matcher.RequestMatcher
+
+/**
+ * A Kotlin DSL to configure DPoP support using idiomatic Kotlin code.
+ *
+ * @author Max Batischev
+ * @property requestMatcher the [RequestMatcher] to use.
+ * @property authenticationConverter the [AuthenticationConverter] to use.
+ * @property successHandler the [AuthenticationSuccessHandler] to use.
+ * @property failureHandler the [AuthenticationFailureHandler] to use.
+ * @since 7.0
+ */
+class DPoPDsl {
+ var requestMatcher: RequestMatcher? = null
+ var authenticationConverter: AuthenticationConverter? = null
+ var successHandler: AuthenticationSuccessHandler? = null
+ var failureHandler: AuthenticationFailureHandler? = null
+
+ internal fun get(): (DPoPAuthenticationConfigurer) -> Unit {
+ return { dpop ->
+ requestMatcher?.also { dpop.requestMatcher(requestMatcher) }
+ authenticationConverter?.also { dpop.authenticationConverter(authenticationConverter) }
+ successHandler?.also { dpop.successHandler(successHandler) }
+ failureHandler?.also { dpop.failureHandler(failureHandler) }
+ }
+ }
+}
diff --git a/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc b/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc
index 15d15b191b7..c324e934544 100644
--- a/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc
+++ b/config/src/main/resources/org/springframework/security/config/spring-security-7.0.rnc
@@ -683,6 +683,19 @@ opaque-token.attlist &=
## Reference to an OpaqueTokenAuthenticationConverter responsible for converting successful introspection result into an Authentication.
attribute authentication-converter-ref {xsd:token}?
+dpop =
+ ## Configuration DpoP
+ element dpop {dpop.attlist}
+dpop.attlist &=
+ ## DPoP Request Matcher
+ attribute dpop-request-matcher-ref {xsd:token}?
+dpop.attlist &=
+ attribute dpop-authentication-converter-ref {xsd:token}?
+dpop.attlist &=
+ attribute dpop-success-handler-ref {xsd:token}?
+dpop.attlist &=
+ attribute dpop-failure-handler-ref {xsd:token}?
+
saml2-login =
## Configures authentication support for SAML 2.0 Login
element saml2-login {saml2-login.attlist}
diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurerTests.java
index 6ffab052e96..3173ef3e3e7 100644
--- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurerTests.java
+++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurerTests.java
@@ -37,6 +37,8 @@
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -51,6 +53,7 @@
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.core.Authentication;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.jose.TestJwks;
import org.springframework.security.oauth2.jose.TestKeys;
@@ -62,12 +65,16 @@
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
@@ -176,6 +183,22 @@ public void requestWhenDPoPAuthenticationValidThenAccessed() throws Exception {
// @formatter:on
}
+ @Test
+ public void requestWhenCustomSuccessHandlerIsPresentThenAccessed() throws Exception {
+ this.spring.register(SecurityConfigWithCustomSuccessHandler.class, ResourceEndpoints.class).autowire();
+ Set scope = Collections.singleton("resource1.read");
+ String accessToken = generateAccessToken(scope, CLIENT_EC_KEY);
+ String dPoPProof = generateDPoPProof(HttpMethod.GET.name(), "http://localhost/resource1", accessToken);
+ // @formatter:off
+ this.mvc.perform(get("/resource1")
+ .header(HttpHeaders.AUTHORIZATION, "DPoP " + accessToken)
+ .header("DPoP", dPoPProof))
+ .andExpect(status().isOk())
+ .andExpect(content().string("resource1"));
+ // @formatter:on
+ verify(SecurityConfigWithCustomSuccessHandler.successHandler).onAuthenticationSuccess(any(), any(), any());
+ }
+
private static String generateAccessToken(Set scope, JWK jwk) {
Map jktClaim = null;
if (jwk != null) {
@@ -248,7 +271,42 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
)
.oauth2ResourceServer((oauth2ResourceServer) ->
oauth2ResourceServer
- .jwt(Customizer.withDefaults()));
+ .jwt(Customizer.withDefaults())
+ .dpop(Customizer.withDefaults())
+ );
+ // @formatter:on
+ return http.build();
+ }
+
+ @Bean
+ NimbusJwtDecoder jwtDecoder() {
+ return NimbusJwtDecoder.withPublicKey(PROVIDER_RSA_PUBLIC_KEY).build();
+ }
+
+ }
+
+ @Configuration
+ @EnableWebSecurity
+ @EnableWebMvc
+ static class SecurityConfigWithCustomSuccessHandler {
+
+ static final CustomSuccessHandler successHandler = spy(CustomSuccessHandler.class);
+
+ @Bean
+ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ // @formatter:off
+ http
+ .authorizeHttpRequests((authorize) ->
+ authorize
+ .requestMatchers("/resource1").hasAnyAuthority("SCOPE_resource1.read", "SCOPE_resource1.write")
+ .requestMatchers("/resource2").hasAnyAuthority("SCOPE_resource2.read", "SCOPE_resource2.write")
+ .anyRequest().authenticated()
+ )
+ .oauth2ResourceServer((oauth2ResourceServer) ->
+ oauth2ResourceServer
+ .jwt(Customizer.withDefaults())
+ .dpop((dpop) -> dpop.successHandler(successHandler))
+ );
// @formatter:on
return http.build();
}
@@ -258,6 +316,15 @@ NimbusJwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(PROVIDER_RSA_PUBLIC_KEY).build();
}
+ static class CustomSuccessHandler implements AuthenticationSuccessHandler {
+
+ @Override
+ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
+ Authentication authentication) {
+ }
+
+ }
+
}
@RestController
diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/DPoPDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/DPoPDslTests.kt
new file mode 100644
index 00000000000..441120450d0
--- /dev/null
+++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/oauth2/resourceserver/DPoPDslTests.kt
@@ -0,0 +1,260 @@
+/*
+ * Copyright 2002-2025 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.config.annotation.web.oauth2.resourceserver
+
+import com.nimbusds.jose.jwk.ECKey
+import com.nimbusds.jose.jwk.JWK
+import com.nimbusds.jose.jwk.JWKSelector
+import com.nimbusds.jose.jwk.JWKSet
+import com.nimbusds.jose.jwk.source.JWKSource
+import com.nimbusds.jose.proc.SecurityContext
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.http.HttpHeaders
+import org.springframework.http.HttpMethod
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
+import org.springframework.security.config.annotation.web.invoke
+import org.springframework.security.config.test.SpringTestContext
+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.*
+import org.springframework.security.web.SecurityFilterChain
+import org.springframework.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+import org.springframework.test.web.servlet.result.MockMvcResultMatchers
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestMethod
+import org.springframework.web.bind.annotation.RestController
+import org.springframework.web.servlet.config.annotation.EnableWebMvc
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
+import java.security.interfaces.ECPrivateKey
+import java.security.interfaces.ECPublicKey
+import java.security.interfaces.RSAPrivateKey
+import java.security.interfaces.RSAPublicKey
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+import java.util.*
+
+/**
+ * Tests for [DPoPDsl]
+ *
+ * @author Max Batischev
+ */
+class DPoPDslTests {
+ @JvmField
+ val spring = SpringTestContext(this)
+
+ @Autowired
+ lateinit var mockMvc: MockMvc
+
+ @Test
+ fun requestWhenDPoPAndBearerAuthenticationThenUnauthorized() {
+ spring.register(SecurityConfig::class.java, ResourceEndpoints::class.java).autowire()
+ val scope = setOf("resource1.read")
+ val accessToken = generateAccessToken(scope, CLIENT_EC_KEY)
+ val dPoPProof = generateDPoPProof(HttpMethod.GET.name(), "http://localhost/resource1", accessToken)
+ // @formatter:off
+ this.mockMvc.perform(MockMvcRequestBuilders.get("/resource1")
+ .header(HttpHeaders.AUTHORIZATION, "DPoP $accessToken")
+ .header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken")
+ .header("DPoP", dPoPProof))
+ .andExpect(MockMvcResultMatchers.status().isUnauthorized())
+ .andExpect(MockMvcResultMatchers.header().string(HttpHeaders.WWW_AUTHENTICATE,
+ "DPoP error=\"invalid_request\", error_description=\"Found multiple Authorization headers.\", algs=\"RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512\""))
+ // @formatter:on
+ }
+
+ @Test
+ fun requestWhenDPoPAccessTokenMalformedThenUnauthorized() {
+ spring.register(SecurityConfig::class.java, ResourceEndpoints::class.java).autowire()
+ val scope = setOf("resource1.read")
+ val accessToken = generateAccessToken(scope, CLIENT_EC_KEY)
+ val dPoPProof = generateDPoPProof(HttpMethod.GET.name(), "http://localhost/resource1", accessToken)
+ // @formatter:off
+ this.mockMvc.perform(MockMvcRequestBuilders.get("/resource1")
+ .header(HttpHeaders.AUTHORIZATION, "DPoP $accessToken m a l f o r m e d ")
+ .header("DPoP", dPoPProof))
+ .andExpect(MockMvcResultMatchers.status().isUnauthorized())
+ .andExpect(MockMvcResultMatchers.header().string(HttpHeaders.WWW_AUTHENTICATE,
+ "DPoP error=\"invalid_token\", error_description=\"DPoP access token is malformed.\", algs=\"RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512\""))
+ // @formatter:on
+ }
+
+ @Test
+ fun requestWhenMultipleDPoPProofsThenUnauthorized() {
+ spring.register(SecurityConfig::class.java, ResourceEndpoints::class.java).autowire()
+ val scope = setOf("resource1.read")
+ val accessToken = generateAccessToken(scope, CLIENT_EC_KEY)
+ val dPoPProof = generateDPoPProof(HttpMethod.GET.name(), "http://localhost/resource1", accessToken)
+ // @formatter:off
+ this.mockMvc.perform(MockMvcRequestBuilders.get("/resource1")
+ .header(HttpHeaders.AUTHORIZATION, "DPoP $accessToken")
+ .header("DPoP", dPoPProof)
+ .header("DPoP", dPoPProof))
+ .andExpect(MockMvcResultMatchers.status().isUnauthorized())
+ .andExpect(MockMvcResultMatchers.header().string(HttpHeaders.WWW_AUTHENTICATE,
+ "DPoP error=\"invalid_request\", error_description=\"DPoP proof is missing or invalid.\", algs=\"RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512\""))
+ // @formatter:on
+ }
+
+ @Test
+ fun requestWhenDPoPAuthenticationValidThenAccessed() {
+ spring.register(SecurityConfig::class.java, ResourceEndpoints::class.java).autowire()
+ val scope = setOf("resource1.read")
+ val accessToken = generateAccessToken(scope, CLIENT_EC_KEY)
+ val dPoPProof = generateDPoPProof(HttpMethod.GET.name(), "http://localhost/resource1", accessToken)
+ // @formatter:off
+ this.mockMvc.perform(MockMvcRequestBuilders.get("/resource1")
+ .header(HttpHeaders.AUTHORIZATION, "DPoP $accessToken")
+ .header("DPoP", dPoPProof))
+ .andExpect(MockMvcResultMatchers.status().isOk())
+ .andExpect(MockMvcResultMatchers.content().string("resource1"))
+ // @formatter:on
+ }
+
+ private fun generateAccessToken(scope: Set, jwk: JWK?): String {
+ var jktClaim: MutableMap? = null
+ if (jwk != null) {
+ try {
+ val sha256Thumbprint = jwk.toPublicJWK().computeThumbprint().toString()
+ jktClaim = HashMap()
+ jktClaim["jkt"] = sha256Thumbprint
+ } catch (ignored: java.lang.Exception) {
+ }
+ }
+ val jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256).build()
+ val issuedAt = Instant.now()
+ val expiresAt = issuedAt.plus(30, ChronoUnit.MINUTES)
+ // @formatter:off
+ val claimsBuilder = JwtClaimsSet.builder()
+ .issuer("https://provider.com")
+ .subject("subject")
+ .issuedAt(issuedAt)
+ .expiresAt(expiresAt)
+ .id(UUID.randomUUID().toString())
+ .claim(OAuth2ParameterNames.SCOPE, scope)
+ if (jktClaim != null) {
+ claimsBuilder.claim("cnf", jktClaim) // Bind client public key
+ }
+ // @formatter:on
+ val jwt = providerJwtEncoder!!.encode(JwtEncoderParameters.from(jwsHeader, claimsBuilder.build()))
+ return jwt.tokenValue
+ }
+
+ private fun generateDPoPProof(method: String, resourceUri: String, accessToken: String): String {
+ // @formatter:off
+ val publicJwk = CLIENT_EC_KEY.toPublicJWK().toJSONObject()
+ val jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+ .type("dpop+jwt")
+ .jwk(publicJwk)
+ .build()
+ val claims = JwtClaimsSet.builder()
+ .issuedAt(Instant.now())
+ .claim("htm", method)
+ .claim("htu", resourceUri)
+ .claim("ath", computeSHA256(accessToken))
+ .id(UUID.randomUUID().toString())
+ .build()
+ // @formatter:on
+ val jwt = clientJwtEncoder!!.encode(JwtEncoderParameters.from(jwsHeader, claims))
+ return jwt.tokenValue
+ }
+
+ private fun computeSHA256(value: String): String {
+ val md = MessageDigest.getInstance("SHA-256")
+ val digest = md.digest(value.toByteArray(StandardCharsets.UTF_8))
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
+ }
+
+ @Configuration
+ @EnableWebSecurity
+ @EnableWebMvc
+ internal open class SecurityConfig {
+
+ @Bean
+ open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
+ http {
+ authorizeHttpRequests {
+ authorize("/resource1", hasAnyAuthority("SCOPE_resource1.read", "SCOPE_resource1.write"))
+ authorize("/resource2", hasAnyAuthority("SCOPE_resource2.read", "SCOPE_resource2.write"))
+ authorize(anyRequest, authenticated)
+ }
+ oauth2ResourceServer {
+ jwt { }
+ dpop { }
+ }
+ }
+ return http.build()
+ }
+
+ @Bean
+ open fun jwtDecoder(): NimbusJwtDecoder = NimbusJwtDecoder.withPublicKey(PROVIDER_RSA_PUBLIC_KEY).build()
+
+ }
+
+ @RestController
+ internal class ResourceEndpoints {
+ @RequestMapping(value = ["/resource1"], method = [RequestMethod.GET, RequestMethod.POST])
+ fun resource1(): String {
+ return "resource1"
+ }
+
+ @RequestMapping(value = ["/resource2"], method = [RequestMethod.GET, RequestMethod.POST])
+ fun resource2(): String {
+ return "resource2"
+ }
+ }
+
+ companion object {
+ private val PROVIDER_RSA_PUBLIC_KEY: RSAPublicKey = TestKeys.DEFAULT_PUBLIC_KEY
+
+ private val PROVIDER_RSA_PRIVATE_KEY: RSAPrivateKey = TestKeys.DEFAULT_PRIVATE_KEY
+
+ private val CLIENT_EC_PUBLIC_KEY = TestKeys.DEFAULT_EC_KEY_PAIR.public as ECPublicKey
+
+ private val CLIENT_EC_PRIVATE_KEY = TestKeys.DEFAULT_EC_KEY_PAIR.private as ECPrivateKey
+
+ private val CLIENT_EC_KEY: ECKey = TestJwks.jwk(CLIENT_EC_PUBLIC_KEY, CLIENT_EC_PRIVATE_KEY).build()
+
+ private var providerJwtEncoder: NimbusJwtEncoder? = null
+
+ private var clientJwtEncoder: NimbusJwtEncoder? = null
+
+ @JvmStatic
+ @BeforeAll
+ fun init() {
+ val providerRsaKey = TestJwks.jwk(PROVIDER_RSA_PUBLIC_KEY, PROVIDER_RSA_PRIVATE_KEY).build()
+ val providerJwkSource = JWKSource { jwkSelector: JWKSelector, _: SecurityContext? ->
+ jwkSelector
+ .select(JWKSet(providerRsaKey))
+ }
+ providerJwtEncoder = NimbusJwtEncoder(providerJwkSource)
+ val clientJwkSource = JWKSource { jwkSelector: JWKSelector, securityContext: SecurityContext? ->
+ jwkSelector
+ .select(JWKSet(CLIENT_EC_KEY))
+ }
+ clientJwtEncoder = NimbusJwtEncoder(clientJwkSource)
+ }
+ }
+}
diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/DPoPAuthenticationConverter.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/DPoPAuthenticationConverter.java
new file mode 100644
index 00000000000..be3c51b9360
--- /dev/null
+++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/DPoPAuthenticationConverter.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2002-2025 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.server.resource.authentication;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+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.web.authentication.AuthenticationConverter;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public final class DPoPAuthenticationConverter implements AuthenticationConverter {
+ private static final Pattern AUTHORIZATION_PATTERN = Pattern.compile("^DPoP (?[a-zA-Z0-9-._~+/]+=*)$",
+ Pattern.CASE_INSENSITIVE);
+
+ @Override
+ public Authentication convert(HttpServletRequest request) {
+ List authorizationList = Collections.list(request.getHeaders(HttpHeaders.AUTHORIZATION));
+ if (CollectionUtils.isEmpty(authorizationList)) {
+ return null;
+ }
+ if (authorizationList.size() != 1) {
+ OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST,
+ "Found multiple Authorization headers.", null);
+ throw new OAuth2AuthenticationException(error);
+ }
+ String authorization = authorizationList.get(0);
+ if (!StringUtils.startsWithIgnoreCase(authorization, OAuth2AccessToken.TokenType.DPOP.getValue())) {
+ return null;
+ }
+ Matcher matcher = AUTHORIZATION_PATTERN.matcher(authorization);
+ if (!matcher.matches()) {
+ OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "DPoP access token is malformed.",
+ null);
+ throw new OAuth2AuthenticationException(error);
+ }
+ String accessToken = matcher.group("token");
+ List dPoPProofList = Collections
+ .list(request.getHeaders(OAuth2AccessToken.TokenType.DPOP.getValue()));
+ if (CollectionUtils.isEmpty(dPoPProofList) || dPoPProofList.size() != 1) {
+ OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST,
+ "DPoP proof is missing or invalid.", null);
+ throw new OAuth2AuthenticationException(error);
+ }
+ String dPoPProof = dPoPProofList.get(0);
+ return new DPoPAuthenticationToken(accessToken, dPoPProof, request.getMethod(),
+ request.getRequestURL().toString());
+ }
+}
diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/DPoPAuthenticationEntryPoint.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/DPoPAuthenticationEntryPoint.java
new file mode 100644
index 00000000000..606fbad0c23
--- /dev/null
+++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/DPoPAuthenticationEntryPoint.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2002-2025 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.server.resource.web;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.util.StringUtils;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public final class DPoPAuthenticationEntryPoint implements AuthenticationEntryPoint {
+
+ @Override
+ public void commence(HttpServletRequest request, HttpServletResponse response,
+ AuthenticationException authenticationException) {
+ Map parameters = new LinkedHashMap<>();
+ if (authenticationException instanceof OAuth2AuthenticationException oauth2AuthenticationException) {
+ OAuth2Error error = oauth2AuthenticationException.getError();
+ parameters.put(OAuth2ParameterNames.ERROR, error.getErrorCode());
+ if (StringUtils.hasText(error.getDescription())) {
+ parameters.put(OAuth2ParameterNames.ERROR_DESCRIPTION, error.getDescription());
+ }
+ if (StringUtils.hasText(error.getUri())) {
+ parameters.put(OAuth2ParameterNames.ERROR_URI, error.getUri());
+ }
+ }
+ parameters.put("algs",
+ JwsAlgorithms.RS256 + " " + JwsAlgorithms.RS384 + " " + JwsAlgorithms.RS512 + " "
+ + JwsAlgorithms.PS256 + " " + JwsAlgorithms.PS384 + " " + JwsAlgorithms.PS512 + " "
+ + JwsAlgorithms.ES256 + " " + JwsAlgorithms.ES384 + " " + JwsAlgorithms.ES512);
+ String wwwAuthenticate = toWWWAuthenticateHeader(parameters);
+ response.addHeader(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticate);
+ response.setStatus(HttpStatus.UNAUTHORIZED.value());
+ }
+
+ private String toWWWAuthenticateHeader(Map parameters) {
+ StringBuilder wwwAuthenticate = new StringBuilder();
+ wwwAuthenticate.append(OAuth2AccessToken.TokenType.DPOP.getValue());
+ if (!parameters.isEmpty()) {
+ wwwAuthenticate.append(" ");
+ int i = 0;
+ for (Map.Entry entry : parameters.entrySet()) {
+ wwwAuthenticate.append(entry.getKey()).append("=\"").append(entry.getValue()).append("\"");
+ if (i++ != parameters.size() - 1) {
+ wwwAuthenticate.append(", ");
+ }
+ }
+ }
+ return wwwAuthenticate.toString();
+ }
+}
diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/DPoPRequestMatcher.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/DPoPRequestMatcher.java
new file mode 100644
index 00000000000..1cdd53483e9
--- /dev/null
+++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/DPoPRequestMatcher.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2002-2025 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.server.resource.web;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.util.StringUtils;
+
+public final class DPoPRequestMatcher implements RequestMatcher {
+
+ @Override
+ public boolean matches(HttpServletRequest request) {
+ String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
+ if (!StringUtils.hasText(authorization)) {
+ return false;
+ }
+ return StringUtils.startsWithIgnoreCase(authorization, OAuth2AccessToken.TokenType.DPOP.getValue());
+ }
+}