From 791feee35584729f45dcb22ed4766aa4ab4de245 Mon Sep 17 00:00:00 2001 From: Joe Grandja <10884212+jgrandja@users.noreply.github.com> Date: Mon, 14 Apr 2025 07:38:08 -0400 Subject: [PATCH] Prevent downgraded usage of DPoP-bound access tokens Issue gh-16574 Closes gh-16937 --- .../BearerTokenAuthenticationFilter.java | 28 ++++++++++++++++++- .../BearerTokenAuthenticationFilterTests.java | 26 ++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/authentication/BearerTokenAuthenticationFilter.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/authentication/BearerTokenAuthenticationFilter.java index 5ffd2ed888..9cad61d0cb 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/authentication/BearerTokenAuthenticationFilter.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/authentication/BearerTokenAuthenticationFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 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. @@ -17,6 +17,7 @@ package org.springframework.security.oauth2.server.resource.web.authentication; import java.io.IOException; +import java.util.Map; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -32,7 +33,11 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.oauth2.core.ClaimAccessor; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.server.resource.BearerTokenError; +import org.springframework.security.oauth2.server.resource.BearerTokenErrors; +import org.springframework.security.oauth2.server.resource.authentication.AbstractOAuth2TokenAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; @@ -45,6 +50,8 @@ import org.springframework.security.web.authentication.WebAuthenticationDetailsS import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; /** @@ -135,6 +142,12 @@ public class BearerTokenAuthenticationFilter extends OncePerRequestFilter { try { AuthenticationManager authenticationManager = this.authenticationManagerResolver.resolve(request); Authentication authenticationResult = authenticationManager.authenticate(authenticationRequest); + if (isDPoPBoundAccessToken(authenticationResult)) { + // Prevent downgraded usage of DPoP-bound access tokens, + // by rejecting a DPoP-bound access token received as a bearer token. + BearerTokenError error = BearerTokenErrors.invalidToken("Invalid bearer token"); + throw new OAuth2AuthenticationException(error); + } SecurityContext context = this.securityContextHolderStrategy.createEmptyContext(); context.setAuthentication(authenticationResult); this.securityContextHolderStrategy.setContext(context); @@ -217,4 +230,17 @@ public class BearerTokenAuthenticationFilter extends OncePerRequestFilter { this.authenticationDetailsSource = authenticationDetailsSource; } + private static boolean isDPoPBoundAccessToken(Authentication authentication) { + if (!(authentication instanceof AbstractOAuth2TokenAuthenticationToken accessTokenAuthentication)) { + return false; + } + ClaimAccessor accessTokenClaims = accessTokenAuthentication::getTokenAttributes; + String jwkThumbprintClaim = null; + Map confirmationMethodClaim = accessTokenClaims.getClaimAsMap("cnf"); + if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) { + jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt"); + } + return StringUtils.hasText(jwkThumbprintClaim); + } + } diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/authentication/BearerTokenAuthenticationFilterTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/authentication/BearerTokenAuthenticationFilterTests.java index 63d306f13e..cc7477684f 100644 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/authentication/BearerTokenAuthenticationFilterTests.java +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/authentication/BearerTokenAuthenticationFilterTests.java @@ -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. @@ -17,6 +17,7 @@ package org.springframework.security.oauth2.server.resource.web.authentication; import java.io.IOException; +import java.util.Collections; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -41,10 +42,14 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.TestJwts; import org.springframework.security.oauth2.server.resource.BearerTokenError; import org.springframework.security.oauth2.server.resource.BearerTokenErrorCodes; import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.authentication.AuthenticationFailureHandler; @@ -239,6 +244,25 @@ public class BearerTokenAuthenticationFilterTests { verify(strategy).setContext(any()); } + @Test + public void doFilterWhenDPoPBoundTokenDowngradedThenPropagatesError() throws ServletException, IOException { + Jwt jwt = TestJwts.jwt().claim("cnf", Collections.singletonMap("jkt", "jwk-thumbprint")).build(); + JwtAuthenticationToken authenticationResult = new JwtAuthenticationToken(jwt); + given(this.bearerTokenResolver.resolve(this.request)).willReturn("token"); + given(this.authenticationManager.authenticate(any(BearerTokenAuthenticationToken.class))) + .willReturn(authenticationResult); + BearerTokenAuthenticationFilter filter = addMocks( + new BearerTokenAuthenticationFilter(this.authenticationManager)); + filter.setAuthenticationFailureHandler(this.authenticationFailureHandler); + filter.doFilter(this.request, this.response, this.filterChain); + ArgumentCaptor exceptionCaptor = ArgumentCaptor + .forClass(OAuth2AuthenticationException.class); + verify(this.authenticationFailureHandler).onAuthenticationFailure(any(), any(), exceptionCaptor.capture()); + OAuth2Error error = exceptionCaptor.getValue().getError(); + assertThat(error.getErrorCode()).isEqualTo(BearerTokenErrorCodes.INVALID_TOKEN); + assertThat(error.getDescription()).isEqualTo("Invalid bearer token"); + } + @Test public void setAuthenticationEntryPointWhenNullThenThrowsException() { BearerTokenAuthenticationFilter filter = new BearerTokenAuthenticationFilter(this.authenticationManager);