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()); + } +}