Browse Source
Signed-off-by: Joe Grandja <10884212+jgrandja@users.noreply.github.com>pull/16575/head
10 changed files with 1923 additions and 2 deletions
@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
|
||||
/* |
||||
* 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.configurers.oauth2.server.resource; |
||||
|
||||
import java.util.Collections; |
||||
import java.util.List; |
||||
import java.util.regex.Matcher; |
||||
import java.util.regex.Pattern; |
||||
|
||||
import jakarta.servlet.http.HttpServletRequest; |
||||
|
||||
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.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.server.resource.authentication.DPoPAuthenticationProvider; |
||||
import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationToken; |
||||
import org.springframework.security.web.authentication.AuthenticationConverter; |
||||
import org.springframework.security.web.authentication.AuthenticationEntryPointFailureHandler; |
||||
import org.springframework.security.web.authentication.AuthenticationFailureHandler; |
||||
import org.springframework.security.web.authentication.AuthenticationFilter; |
||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler; |
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint; |
||||
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; |
||||
|
||||
/** |
||||
* @author Joe Grandja |
||||
* @since 6.5 |
||||
* @see DPoPAuthenticationProvider |
||||
*/ |
||||
final class DPoPAuthenticationConfigurer<B extends HttpSecurityBuilder<B>> |
||||
extends AbstractHttpConfigurer<DPoPAuthenticationConfigurer<B>, B> { |
||||
|
||||
private RequestMatcher requestMatcher; |
||||
|
||||
private AuthenticationConverter authenticationConverter; |
||||
|
||||
private AuthenticationSuccessHandler authenticationSuccessHandler; |
||||
|
||||
private AuthenticationFailureHandler authenticationFailureHandler; |
||||
|
||||
@Override |
||||
public void configure(B http) { |
||||
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class); |
||||
http.authenticationProvider(new DPoPAuthenticationProvider(authenticationManager)); |
||||
AuthenticationFilter authenticationFilter = new AuthenticationFilter(authenticationManager, |
||||
getAuthenticationConverter()); |
||||
authenticationFilter.setRequestMatcher(getRequestMatcher()); |
||||
authenticationFilter.setSuccessHandler(getAuthenticationSuccessHandler()); |
||||
authenticationFilter.setFailureHandler(getAuthenticationFailureHandler()); |
||||
authenticationFilter.setSecurityContextRepository(new RequestAttributeSecurityContextRepository()); |
||||
authenticationFilter = postProcess(authenticationFilter); |
||||
http.addFilter(authenticationFilter); |
||||
} |
||||
|
||||
private RequestMatcher getRequestMatcher() { |
||||
if (this.requestMatcher == null) { |
||||
this.requestMatcher = new DPoPRequestMatcher(); |
||||
} |
||||
return this.requestMatcher; |
||||
} |
||||
|
||||
private AuthenticationConverter getAuthenticationConverter() { |
||||
if (this.authenticationConverter == null) { |
||||
this.authenticationConverter = new DPoPAuthenticationConverter(); |
||||
} |
||||
return this.authenticationConverter; |
||||
} |
||||
|
||||
private AuthenticationSuccessHandler getAuthenticationSuccessHandler() { |
||||
if (this.authenticationSuccessHandler == null) { |
||||
this.authenticationSuccessHandler = (request, response, authentication) -> { |
||||
// No-op - will continue on filter chain
|
||||
}; |
||||
} |
||||
return this.authenticationSuccessHandler; |
||||
} |
||||
|
||||
private AuthenticationFailureHandler getAuthenticationFailureHandler() { |
||||
if (this.authenticationFailureHandler == null) { |
||||
this.authenticationFailureHandler = new AuthenticationEntryPointFailureHandler( |
||||
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)); |
||||
} |
||||
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 (?<token>[a-zA-Z0-9-._~+/]+=*)$", |
||||
Pattern.CASE_INSENSITIVE); |
||||
|
||||
@Override |
||||
public Authentication convert(HttpServletRequest request) { |
||||
List<String> 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<String> 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()); |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,279 @@
@@ -0,0 +1,279 @@
|
||||
/* |
||||
* 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.configurers.oauth2.server.resource; |
||||
|
||||
import java.nio.charset.StandardCharsets; |
||||
import java.security.MessageDigest; |
||||
import java.security.PublicKey; |
||||
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.Base64; |
||||
import java.util.Collections; |
||||
import java.util.HashMap; |
||||
import java.util.Map; |
||||
import java.util.Set; |
||||
import java.util.UUID; |
||||
|
||||
import com.nimbusds.jose.jwk.ECKey; |
||||
import com.nimbusds.jose.jwk.JWKSet; |
||||
import com.nimbusds.jose.jwk.RSAKey; |
||||
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.junit.jupiter.api.extension.ExtendWith; |
||||
|
||||
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.Customizer; |
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity; |
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; |
||||
import org.springframework.security.config.test.SpringTestContext; |
||||
import org.springframework.security.config.test.SpringTestContextExtension; |
||||
import org.springframework.security.oauth2.core.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.JwsHeader; |
||||
import org.springframework.security.oauth2.jwt.Jwt; |
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet; |
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters; |
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; |
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; |
||||
import org.springframework.security.web.SecurityFilterChain; |
||||
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.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.status; |
||||
|
||||
/** |
||||
* Tests for {@link DPoPAuthenticationConfigurer}. |
||||
* |
||||
* @author Joe Grandja |
||||
*/ |
||||
@ExtendWith(SpringTestContextExtension.class) |
||||
public class DPoPAuthenticationConfigurerTests { |
||||
|
||||
private static final RSAPublicKey PROVIDER_RSA_PUBLIC_KEY = TestKeys.DEFAULT_PUBLIC_KEY; |
||||
|
||||
private static final RSAPrivateKey PROVIDER_RSA_PRIVATE_KEY = TestKeys.DEFAULT_PRIVATE_KEY; |
||||
|
||||
private static final ECPublicKey CLIENT_EC_PUBLIC_KEY = (ECPublicKey) TestKeys.DEFAULT_EC_KEY_PAIR.getPublic(); |
||||
|
||||
private static final ECPrivateKey CLIENT_EC_PRIVATE_KEY = (ECPrivateKey) TestKeys.DEFAULT_EC_KEY_PAIR.getPrivate(); |
||||
|
||||
private static NimbusJwtEncoder providerJwtEncoder; |
||||
|
||||
private static NimbusJwtEncoder clientJwtEncoder; |
||||
|
||||
public final SpringTestContext spring = new SpringTestContext(this); |
||||
|
||||
@Autowired |
||||
private MockMvc mvc; |
||||
|
||||
@BeforeAll |
||||
public static void init() { |
||||
RSAKey providerRsaKey = TestJwks.jwk(PROVIDER_RSA_PUBLIC_KEY, PROVIDER_RSA_PRIVATE_KEY).build(); |
||||
JWKSource<SecurityContext> providerJwkSource = (jwkSelector, securityContext) -> jwkSelector |
||||
.select(new JWKSet(providerRsaKey)); |
||||
providerJwtEncoder = new NimbusJwtEncoder(providerJwkSource); |
||||
ECKey clientEcKey = TestJwks.jwk(CLIENT_EC_PUBLIC_KEY, CLIENT_EC_PRIVATE_KEY).build(); |
||||
JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector |
||||
.select(new JWKSet(clientEcKey)); |
||||
clientJwtEncoder = new NimbusJwtEncoder(clientJwkSource); |
||||
} |
||||
|
||||
@Test |
||||
public void requestWhenDPoPAndBearerAuthenticationThenUnauthorized() throws Exception { |
||||
this.spring.register(SecurityConfig.class, ResourceEndpoints.class).autowire(); |
||||
Set<String> scope = Collections.singleton("resource1.read"); |
||||
String accessToken = generateAccessToken(scope, CLIENT_EC_PUBLIC_KEY); |
||||
String dPoPProof = generateDPoPProof(HttpMethod.GET.name(), "http://localhost/resource1", accessToken); |
||||
// @formatter:off
|
||||
this.mvc.perform(get("/resource1") |
||||
.header(HttpHeaders.AUTHORIZATION, "DPoP " + accessToken) |
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) |
||||
.header("DPoP", dPoPProof)) |
||||
.andExpect(status().isUnauthorized()); |
||||
// @formatter:on
|
||||
} |
||||
|
||||
@Test |
||||
public void requestWhenDPoPAccessTokenMalformedThenUnauthorized() throws Exception { |
||||
this.spring.register(SecurityConfig.class, ResourceEndpoints.class).autowire(); |
||||
Set<String> scope = Collections.singleton("resource1.read"); |
||||
String accessToken = generateAccessToken(scope, CLIENT_EC_PUBLIC_KEY); |
||||
String dPoPProof = generateDPoPProof(HttpMethod.GET.name(), "http://localhost/resource1", accessToken); |
||||
// @formatter:off
|
||||
this.mvc.perform(get("/resource1") |
||||
.header(HttpHeaders.AUTHORIZATION, "DPoP " + accessToken + " m a l f o r m e d ") |
||||
.header("DPoP", dPoPProof)) |
||||
.andExpect(status().isUnauthorized()); |
||||
// @formatter:on
|
||||
} |
||||
|
||||
@Test |
||||
public void requestWhenMultipleDPoPProofsThenUnauthorized() throws Exception { |
||||
this.spring.register(SecurityConfig.class, ResourceEndpoints.class).autowire(); |
||||
Set<String> scope = Collections.singleton("resource1.read"); |
||||
String accessToken = generateAccessToken(scope, CLIENT_EC_PUBLIC_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) |
||||
.header("DPoP", dPoPProof)) |
||||
.andExpect(status().isUnauthorized()); |
||||
// @formatter:on
|
||||
} |
||||
|
||||
@Test |
||||
public void requestWhenDPoPAuthenticationValidThenAccessed() throws Exception { |
||||
this.spring.register(SecurityConfig.class, ResourceEndpoints.class).autowire(); |
||||
Set<String> scope = Collections.singleton("resource1.read"); |
||||
String accessToken = generateAccessToken(scope, CLIENT_EC_PUBLIC_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
|
||||
} |
||||
|
||||
private static String generateAccessToken(Set<String> scope, PublicKey clientPublicKey) { |
||||
Map<String, Object> jktClaim = null; |
||||
if (clientPublicKey != null) { |
||||
try { |
||||
String sha256Thumbprint = computeSHA256(clientPublicKey); |
||||
jktClaim = new HashMap<>(); |
||||
jktClaim.put("jkt", sha256Thumbprint); |
||||
} |
||||
catch (Exception ignored) { |
||||
} |
||||
} |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256).build(); |
||||
Instant issuedAt = Instant.now(); |
||||
Instant expiresAt = issuedAt.plus(30, ChronoUnit.MINUTES); |
||||
// @formatter:off
|
||||
JwtClaimsSet.Builder 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
|
||||
Jwt jwt = providerJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claimsBuilder.build())); |
||||
return jwt.getTokenValue(); |
||||
} |
||||
|
||||
private static String generateDPoPProof(String method, String resourceUri, String accessToken) throws Exception { |
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = TestJwks.jwk(CLIENT_EC_PUBLIC_KEY, CLIENT_EC_PRIVATE_KEY) |
||||
.build() |
||||
.toPublicJWK() |
||||
.toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256) |
||||
.type("dpop+jwt") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(Instant.now()) |
||||
.claim("htm", method) |
||||
.claim("htu", resourceUri) |
||||
.claim("ath", computeSHA256(accessToken)) |
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
Jwt jwt = clientJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
return jwt.getTokenValue(); |
||||
} |
||||
|
||||
private static String computeSHA256(String value) throws Exception { |
||||
MessageDigest md = MessageDigest.getInstance("SHA-256"); |
||||
byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8)); |
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); |
||||
} |
||||
|
||||
private static String computeSHA256(PublicKey publicKey) throws Exception { |
||||
MessageDigest md = MessageDigest.getInstance("SHA-256"); |
||||
byte[] digest = md.digest(publicKey.getEncoded()); |
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); |
||||
} |
||||
|
||||
@Configuration |
||||
@EnableWebSecurity |
||||
@EnableWebMvc |
||||
static class SecurityConfig { |
||||
|
||||
@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())); |
||||
// @formatter:on
|
||||
return http.build(); |
||||
} |
||||
|
||||
@Bean |
||||
NimbusJwtDecoder jwtDecoder() { |
||||
return NimbusJwtDecoder.withPublicKey(PROVIDER_RSA_PUBLIC_KEY).build(); |
||||
} |
||||
|
||||
} |
||||
|
||||
@RestController |
||||
static class ResourceEndpoints { |
||||
|
||||
@RequestMapping(value = "/resource1", method = { RequestMethod.GET, RequestMethod.POST }) |
||||
String resource1() { |
||||
return "resource1"; |
||||
} |
||||
|
||||
@RequestMapping(value = "/resource2", method = { RequestMethod.GET, RequestMethod.POST }) |
||||
String resource2() { |
||||
return "resource2"; |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
|
||||
/* |
||||
* 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.jwt; |
||||
|
||||
import java.net.URI; |
||||
|
||||
import org.springframework.lang.Nullable; |
||||
import org.springframework.security.oauth2.core.OAuth2Token; |
||||
import org.springframework.util.Assert; |
||||
|
||||
/** |
||||
* @author Joe Grandja |
||||
* @since 6.5 |
||||
* @see DPoPProofJwtDecoderFactory |
||||
*/ |
||||
public final class DPoPProofContext { |
||||
|
||||
private final String dPoPProof; |
||||
|
||||
private final String method; |
||||
|
||||
private final String targetUri; |
||||
|
||||
private final OAuth2Token accessToken; |
||||
|
||||
private DPoPProofContext(String dPoPProof, String method, String targetUri, @Nullable OAuth2Token accessToken) { |
||||
this.dPoPProof = dPoPProof; |
||||
this.method = method; |
||||
this.targetUri = targetUri; |
||||
this.accessToken = accessToken; |
||||
} |
||||
|
||||
public String getDPoPProof() { |
||||
return this.dPoPProof; |
||||
} |
||||
|
||||
public String getMethod() { |
||||
return this.method; |
||||
} |
||||
|
||||
public String getTargetUri() { |
||||
return this.targetUri; |
||||
} |
||||
|
||||
@SuppressWarnings("unchecked") |
||||
@Nullable |
||||
public <T extends OAuth2Token> T getAccessToken() { |
||||
return (T) this.accessToken; |
||||
} |
||||
|
||||
public static Builder withDPoPProof(String dPoPProof) { |
||||
return new Builder(dPoPProof); |
||||
} |
||||
|
||||
public static final class Builder { |
||||
|
||||
private String dPoPProof; |
||||
|
||||
private String method; |
||||
|
||||
private String targetUri; |
||||
|
||||
private OAuth2Token accessToken; |
||||
|
||||
private Builder(String dPoPProof) { |
||||
Assert.hasText(dPoPProof, "dPoPProof cannot be empty"); |
||||
this.dPoPProof = dPoPProof; |
||||
} |
||||
|
||||
public Builder method(String method) { |
||||
this.method = method; |
||||
return this; |
||||
} |
||||
|
||||
public Builder targetUri(String targetUri) { |
||||
this.targetUri = targetUri; |
||||
return this; |
||||
} |
||||
|
||||
public Builder accessToken(OAuth2Token accessToken) { |
||||
this.accessToken = accessToken; |
||||
return this; |
||||
} |
||||
|
||||
public DPoPProofContext build() { |
||||
validate(); |
||||
return new DPoPProofContext(this.dPoPProof, this.method, this.targetUri, this.accessToken); |
||||
} |
||||
|
||||
private void validate() { |
||||
Assert.hasText(this.method, "method cannot be empty"); |
||||
Assert.hasText(this.targetUri, "targetUri cannot be empty"); |
||||
if (!"GET".equals(this.method) && !"HEAD".equals(this.method) && !"POST".equals(this.method) |
||||
&& !"PUT".equals(this.method) && !"PATCH".equals(this.method) && !"DELETE".equals(this.method) |
||||
&& !"OPTIONS".equals(this.method) && !"TRACE".equals(this.method)) { |
||||
throw new IllegalArgumentException("method is invalid"); |
||||
} |
||||
URI uri; |
||||
try { |
||||
uri = new URI(this.targetUri); |
||||
uri.toURL(); |
||||
} |
||||
catch (Exception ex) { |
||||
throw new IllegalArgumentException("targetUri must be a valid URL", ex); |
||||
} |
||||
if (uri.getQuery() != null || uri.getFragment() != null) { |
||||
throw new IllegalArgumentException("targetUri cannot contain query or fragment parts"); |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,203 @@
@@ -0,0 +1,203 @@
|
||||
/* |
||||
* 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.jwt; |
||||
|
||||
import java.nio.charset.StandardCharsets; |
||||
import java.security.MessageDigest; |
||||
import java.time.Clock; |
||||
import java.time.Duration; |
||||
import java.time.Instant; |
||||
import java.util.Base64; |
||||
import java.util.Collections; |
||||
import java.util.Map; |
||||
import java.util.concurrent.ConcurrentHashMap; |
||||
import java.util.function.Function; |
||||
|
||||
import com.nimbusds.jose.JOSEException; |
||||
import com.nimbusds.jose.JOSEObjectType; |
||||
import com.nimbusds.jose.JWSAlgorithm; |
||||
import com.nimbusds.jose.jwk.ECKey; |
||||
import com.nimbusds.jose.jwk.JWK; |
||||
import com.nimbusds.jose.jwk.RSAKey; |
||||
import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; |
||||
import com.nimbusds.jose.proc.JOSEObjectTypeVerifier; |
||||
import com.nimbusds.jose.proc.JWSKeySelector; |
||||
import com.nimbusds.jose.proc.SecurityContext; |
||||
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; |
||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor; |
||||
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; |
||||
import org.springframework.security.oauth2.core.OAuth2Error; |
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes; |
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator; |
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; |
||||
import org.springframework.util.Assert; |
||||
import org.springframework.util.StringUtils; |
||||
|
||||
/** |
||||
* @author Joe Grandja |
||||
* @since 6.5 |
||||
* @see DPoPProofContext |
||||
*/ |
||||
public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPProofContext> { |
||||
|
||||
private static final JOSEObjectTypeVerifier<SecurityContext> DPOP_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>( |
||||
new JOSEObjectType("dpop+jwt")); |
||||
|
||||
public static final Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = defaultJwtValidatorFactory(); |
||||
|
||||
private Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = DEFAULT_JWT_VALIDATOR_FACTORY; |
||||
|
||||
@Override |
||||
public JwtDecoder createDecoder(DPoPProofContext dPoPProofContext) { |
||||
Assert.notNull(dPoPProofContext, "dPoPProofContext cannot be null"); |
||||
NimbusJwtDecoder jwtDecoder = buildDecoder(); |
||||
jwtDecoder.setJwtValidator(this.jwtValidatorFactory.apply(dPoPProofContext)); |
||||
return jwtDecoder; |
||||
} |
||||
|
||||
public void setJwtValidatorFactory(Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory) { |
||||
Assert.notNull(jwtValidatorFactory, "jwtValidatorFactory cannot be null"); |
||||
this.jwtValidatorFactory = jwtValidatorFactory; |
||||
} |
||||
|
||||
private static NimbusJwtDecoder buildDecoder() { |
||||
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>(); |
||||
jwtProcessor.setJWSTypeVerifier(DPOP_TYPE_VERIFIER); |
||||
jwtProcessor.setJWSKeySelector(jwsKeySelector()); |
||||
// Override the default Nimbus claims set verifier and use jwtValidatorFactory for
|
||||
// claims validation
|
||||
jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> { |
||||
}); |
||||
return new NimbusJwtDecoder(jwtProcessor); |
||||
} |
||||
|
||||
private static JWSKeySelector<SecurityContext> jwsKeySelector() { |
||||
return (header, context) -> { |
||||
JWSAlgorithm algorithm = header.getAlgorithm(); |
||||
if (!JWSAlgorithm.Family.RSA.contains(algorithm) && !JWSAlgorithm.Family.EC.contains(algorithm)) { |
||||
throw new BadJwtException("Unsupported alg parameter in JWS Header: " + algorithm.getName()); |
||||
} |
||||
|
||||
JWK jwk = header.getJWK(); |
||||
if (jwk == null) { |
||||
throw new BadJwtException("Missing jwk parameter in JWS Header."); |
||||
} |
||||
if (jwk.isPrivate()) { |
||||
throw new BadJwtException("Invalid jwk parameter in JWS Header."); |
||||
} |
||||
|
||||
try { |
||||
if (JWSAlgorithm.Family.RSA.contains(algorithm) && jwk instanceof RSAKey rsaKey) { |
||||
return Collections.singletonList(rsaKey.toRSAPublicKey()); |
||||
} |
||||
else if (JWSAlgorithm.Family.EC.contains(algorithm) && jwk instanceof ECKey ecKey) { |
||||
return Collections.singletonList(ecKey.toECPublicKey()); |
||||
} |
||||
} |
||||
catch (JOSEException ex) { |
||||
throw new BadJwtException("Invalid jwk parameter in JWS Header."); |
||||
} |
||||
|
||||
throw new BadJwtException("Invalid alg / jwk parameter in JWS Header: alg=" + algorithm.getName() |
||||
+ ", jwk.kty=" + jwk.getKeyType().getValue()); |
||||
}; |
||||
} |
||||
|
||||
private static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> defaultJwtValidatorFactory() { |
||||
return (context) -> new DelegatingOAuth2TokenValidator<>( |
||||
new JwtClaimValidator<>("htm", context.getMethod()::equals), |
||||
new JwtClaimValidator<>("htu", context.getTargetUri()::equals), new JtiClaimValidator(), |
||||
new IatClaimValidator()); |
||||
} |
||||
|
||||
private static final class JtiClaimValidator implements OAuth2TokenValidator<Jwt> { |
||||
|
||||
private static final Map<String, Long> jtiCache = new ConcurrentHashMap<>(); |
||||
|
||||
@Override |
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) { |
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null"); |
||||
String jti = jwt.getId(); |
||||
if (!StringUtils.hasText(jti)) { |
||||
OAuth2Error error = createOAuth2Error("jti claim is required."); |
||||
return OAuth2TokenValidatorResult.failure(error); |
||||
} |
||||
|
||||
// Enforce single-use to protect against DPoP proof replay
|
||||
String jtiHash; |
||||
try { |
||||
jtiHash = computeSHA256(jti); |
||||
} |
||||
catch (Exception ex) { |
||||
OAuth2Error error = createOAuth2Error("jti claim is invalid."); |
||||
return OAuth2TokenValidatorResult.failure(error); |
||||
} |
||||
Instant now = Instant.now(Clock.systemUTC()); |
||||
if ((jtiCache.putIfAbsent(jtiHash, now.toEpochMilli())) != null) { |
||||
// Already used
|
||||
OAuth2Error error = createOAuth2Error("jti claim is invalid."); |
||||
return OAuth2TokenValidatorResult.failure(error); |
||||
} |
||||
return OAuth2TokenValidatorResult.success(); |
||||
} |
||||
|
||||
private static OAuth2Error createOAuth2Error(String reason) { |
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null); |
||||
} |
||||
|
||||
private static String computeSHA256(String value) throws Exception { |
||||
MessageDigest md = MessageDigest.getInstance("SHA-256"); |
||||
byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8)); |
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); |
||||
} |
||||
|
||||
} |
||||
|
||||
private static final class IatClaimValidator implements OAuth2TokenValidator<Jwt> { |
||||
|
||||
private final Duration clockSkew = Duration.ofSeconds(60); |
||||
|
||||
private final Clock clock = Clock.systemUTC(); |
||||
|
||||
@Override |
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) { |
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null"); |
||||
Instant issuedAt = jwt.getIssuedAt(); |
||||
if (issuedAt == null) { |
||||
OAuth2Error error = createOAuth2Error("iat claim is required."); |
||||
return OAuth2TokenValidatorResult.failure(error); |
||||
} |
||||
|
||||
// Check time window of validity
|
||||
Instant now = Instant.now(this.clock); |
||||
Instant notBefore = now.minus(this.clockSkew); |
||||
Instant notAfter = now.plus(this.clockSkew); |
||||
if (issuedAt.isBefore(notBefore) || issuedAt.isAfter(notAfter)) { |
||||
OAuth2Error error = createOAuth2Error("iat claim is invalid."); |
||||
return OAuth2TokenValidatorResult.failure(error); |
||||
} |
||||
return OAuth2TokenValidatorResult.success(); |
||||
} |
||||
|
||||
private static OAuth2Error createOAuth2Error(String reason) { |
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null); |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,451 @@
@@ -0,0 +1,451 @@
|
||||
/* |
||||
* 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.jwt; |
||||
|
||||
import java.time.Duration; |
||||
import java.time.Instant; |
||||
import java.util.Collections; |
||||
import java.util.Map; |
||||
import java.util.UUID; |
||||
|
||||
import com.nimbusds.jose.jwk.RSAKey; |
||||
import com.nimbusds.jose.jwk.source.JWKSource; |
||||
import com.nimbusds.jose.proc.SecurityContext; |
||||
import org.junit.jupiter.api.BeforeEach; |
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import org.springframework.security.oauth2.jose.TestJwks; |
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; |
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType; |
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; |
||||
import static org.mockito.ArgumentMatchers.any; |
||||
import static org.mockito.BDDMockito.given; |
||||
import static org.mockito.Mockito.mock; |
||||
|
||||
/** |
||||
* Tests for {@link DPoPProofJwtDecoderFactory}. |
||||
* |
||||
* @author Joe Grandja |
||||
*/ |
||||
public class DPoPProofJwtDecoderFactoryTests { |
||||
|
||||
private JWKSource<SecurityContext> jwkSource; |
||||
|
||||
private NimbusJwtEncoder jwtEncoder; |
||||
|
||||
private DPoPProofJwtDecoderFactory jwtDecoderFactory = new DPoPProofJwtDecoderFactory(); |
||||
|
||||
@BeforeEach |
||||
public void setUp() { |
||||
this.jwkSource = mock(JWKSource.class); |
||||
this.jwtEncoder = new NimbusJwtEncoder(this.jwkSource); |
||||
} |
||||
|
||||
@Test |
||||
public void setJwtValidatorFactoryWhenNullThenThrowIllegalArgumentException() { |
||||
assertThatIllegalArgumentException().isThrownBy(() -> this.jwtDecoderFactory.setJwtValidatorFactory(null)) |
||||
.withMessage("jwtValidatorFactory cannot be null"); |
||||
} |
||||
|
||||
@Test |
||||
public void createDecoderWhenContextNullThenThrowIllegalArgumentException() { |
||||
assertThatIllegalArgumentException().isThrownBy(() -> this.jwtDecoderFactory.createDecoder(null)) |
||||
.withMessage("dPoPProofContext cannot be null"); |
||||
} |
||||
|
||||
@Test |
||||
public void decodeWhenJoseTypeInvalidThenThrowBadJwtException() throws Exception { |
||||
RSAKey rsaJwk = TestJwks.DEFAULT_RSA_JWK; |
||||
given(this.jwkSource.get(any(), any())).willReturn(Collections.singletonList(rsaJwk)); |
||||
|
||||
String method = "GET"; |
||||
String targetUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = rsaJwk.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("invalid-type") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(Instant.now()) |
||||
.claim("htm", method) |
||||
.claim("htu", targetUri) |
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
// @formatter:off
|
||||
DPoPProofContext dPoPProofContext = DPoPProofContext.withDPoPProof(dPoPProof.getTokenValue()) |
||||
.method(method) |
||||
.targetUri(targetUri) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
JwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(dPoPProofContext); |
||||
|
||||
assertThatExceptionOfType(BadJwtException.class) |
||||
.isThrownBy(() -> jwtDecoder.decode(dPoPProofContext.getDPoPProof())) |
||||
.withMessageContaining("JOSE header typ (type) invalid-type not allowed"); |
||||
} |
||||
|
||||
@Test |
||||
public void decodeWhenJwkMissingThenThrowBadJwtException() throws Exception { |
||||
RSAKey rsaJwk = TestJwks.DEFAULT_RSA_JWK; |
||||
given(this.jwkSource.get(any(), any())).willReturn(Collections.singletonList(rsaJwk)); |
||||
|
||||
String method = "GET"; |
||||
String targetUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = rsaJwk.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("dpop+jwt") |
||||
// .jwk(publicJwk)
|
||||
.build(); |
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(Instant.now()) |
||||
.claim("htm", method) |
||||
.claim("htu", targetUri) |
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
// @formatter:off
|
||||
DPoPProofContext dPoPProofContext = DPoPProofContext.withDPoPProof(dPoPProof.getTokenValue()) |
||||
.method(method) |
||||
.targetUri(targetUri) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
JwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(dPoPProofContext); |
||||
|
||||
assertThatExceptionOfType(BadJwtException.class) |
||||
.isThrownBy(() -> jwtDecoder.decode(dPoPProofContext.getDPoPProof())) |
||||
.withMessageContaining("Missing jwk parameter in JWS Header."); |
||||
} |
||||
|
||||
@Test |
||||
public void decodeWhenMethodInvalidThenThrowBadJwtException() throws Exception { |
||||
RSAKey rsaJwk = TestJwks.DEFAULT_RSA_JWK; |
||||
given(this.jwkSource.get(any(), any())).willReturn(Collections.singletonList(rsaJwk)); |
||||
|
||||
String method = "GET"; |
||||
String targetUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = rsaJwk.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("dpop+jwt") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(Instant.now()) |
||||
.claim("htm", method) |
||||
.claim("htu", targetUri) |
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
// @formatter:off
|
||||
DPoPProofContext dPoPProofContext = DPoPProofContext.withDPoPProof(dPoPProof.getTokenValue()) |
||||
.method("POST") // Mismatch
|
||||
.targetUri(targetUri) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
JwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(dPoPProofContext); |
||||
|
||||
assertThatExceptionOfType(BadJwtException.class) |
||||
.isThrownBy(() -> jwtDecoder.decode(dPoPProofContext.getDPoPProof())) |
||||
.withMessageContaining("The htm claim is not valid"); |
||||
} |
||||
|
||||
@Test |
||||
public void decodeWhenTargetUriInvalidThenThrowBadJwtException() throws Exception { |
||||
RSAKey rsaJwk = TestJwks.DEFAULT_RSA_JWK; |
||||
given(this.jwkSource.get(any(), any())).willReturn(Collections.singletonList(rsaJwk)); |
||||
|
||||
String method = "GET"; |
||||
String targetUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = rsaJwk.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("dpop+jwt") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(Instant.now()) |
||||
.claim("htm", method) |
||||
.claim("htu", targetUri) |
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
// @formatter:off
|
||||
DPoPProofContext dPoPProofContext = DPoPProofContext.withDPoPProof(dPoPProof.getTokenValue()) |
||||
.method(method) |
||||
.targetUri("https://resource2") // Mismatch
|
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
JwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(dPoPProofContext); |
||||
|
||||
assertThatExceptionOfType(BadJwtException.class) |
||||
.isThrownBy(() -> jwtDecoder.decode(dPoPProofContext.getDPoPProof())) |
||||
.withMessageContaining("The htu claim is not valid"); |
||||
} |
||||
|
||||
@Test |
||||
public void decodeWhenJtiMissingThenThrowBadJwtException() throws Exception { |
||||
RSAKey rsaJwk = TestJwks.DEFAULT_RSA_JWK; |
||||
given(this.jwkSource.get(any(), any())).willReturn(Collections.singletonList(rsaJwk)); |
||||
|
||||
String method = "GET"; |
||||
String targetUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = rsaJwk.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("dpop+jwt") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(Instant.now()) |
||||
.claim("htm", method) |
||||
.claim("htu", targetUri) |
||||
// .id(UUID.randomUUID().toString())
|
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
// @formatter:off
|
||||
DPoPProofContext dPoPProofContext = DPoPProofContext.withDPoPProof(dPoPProof.getTokenValue()) |
||||
.method(method) |
||||
.targetUri(targetUri) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
JwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(dPoPProofContext); |
||||
|
||||
assertThatExceptionOfType(BadJwtException.class) |
||||
.isThrownBy(() -> jwtDecoder.decode(dPoPProofContext.getDPoPProof())) |
||||
.withMessageContaining("jti claim is required"); |
||||
} |
||||
|
||||
@Test |
||||
public void decodeWhenJtiAlreadyUsedThenThrowBadJwtException() throws Exception { |
||||
RSAKey rsaJwk = TestJwks.DEFAULT_RSA_JWK; |
||||
given(this.jwkSource.get(any(), any())).willReturn(Collections.singletonList(rsaJwk)); |
||||
|
||||
String method = "GET"; |
||||
String targetUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = rsaJwk.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("dpop+jwt") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(Instant.now()) |
||||
.claim("htm", method) |
||||
.claim("htu", targetUri) |
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
// @formatter:off
|
||||
DPoPProofContext dPoPProofContext = DPoPProofContext.withDPoPProof(dPoPProof.getTokenValue()) |
||||
.method(method) |
||||
.targetUri(targetUri) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
JwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(dPoPProofContext); |
||||
|
||||
jwtDecoder.decode(dPoPProofContext.getDPoPProof()); |
||||
assertThatExceptionOfType(BadJwtException.class) |
||||
.isThrownBy(() -> jwtDecoder.decode(dPoPProofContext.getDPoPProof())) |
||||
.withMessageContaining("jti claim is invalid"); |
||||
} |
||||
|
||||
@Test |
||||
public void decodeWhenIatMissingThenThrowBadJwtException() throws Exception { |
||||
RSAKey rsaJwk = TestJwks.DEFAULT_RSA_JWK; |
||||
given(this.jwkSource.get(any(), any())).willReturn(Collections.singletonList(rsaJwk)); |
||||
|
||||
String method = "GET"; |
||||
String targetUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = rsaJwk.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("dpop+jwt") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
// .issuedAt(Instant.now())
|
||||
.claim("htm", method) |
||||
.claim("htu", targetUri) |
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
// @formatter:off
|
||||
DPoPProofContext dPoPProofContext = DPoPProofContext.withDPoPProof(dPoPProof.getTokenValue()) |
||||
.method(method) |
||||
.targetUri(targetUri) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
JwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(dPoPProofContext); |
||||
|
||||
assertThatExceptionOfType(BadJwtException.class) |
||||
.isThrownBy(() -> jwtDecoder.decode(dPoPProofContext.getDPoPProof())) |
||||
.withMessageContaining("iat claim is required"); |
||||
} |
||||
|
||||
@Test |
||||
public void decodeWhenIatBeforeTimeWindowThenThrowBadJwtException() throws Exception { |
||||
RSAKey rsaJwk = TestJwks.DEFAULT_RSA_JWK; |
||||
given(this.jwkSource.get(any(), any())).willReturn(Collections.singletonList(rsaJwk)); |
||||
|
||||
String method = "GET"; |
||||
String targetUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = rsaJwk.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("dpop+jwt") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
Instant issuedAt = Instant.now().minus(Duration.ofSeconds(65)); // now minus 65 seconds
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(issuedAt) |
||||
.claim("htm", method) |
||||
.claim("htu", targetUri) |
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
// @formatter:off
|
||||
DPoPProofContext dPoPProofContext = DPoPProofContext.withDPoPProof(dPoPProof.getTokenValue()) |
||||
.method(method) |
||||
.targetUri(targetUri) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
JwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(dPoPProofContext); |
||||
|
||||
assertThatExceptionOfType(BadJwtException.class) |
||||
.isThrownBy(() -> jwtDecoder.decode(dPoPProofContext.getDPoPProof())) |
||||
.withMessageContaining("iat claim is invalid"); |
||||
} |
||||
|
||||
@Test |
||||
public void decodeWhenIatAfterTimeWindowThenThrowBadJwtException() throws Exception { |
||||
RSAKey rsaJwk = TestJwks.DEFAULT_RSA_JWK; |
||||
given(this.jwkSource.get(any(), any())).willReturn(Collections.singletonList(rsaJwk)); |
||||
|
||||
String method = "GET"; |
||||
String targetUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = rsaJwk.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("dpop+jwt") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
Instant issuedAt = Instant.now().plus(Duration.ofSeconds(65)); // now plus 65 seconds
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(issuedAt) |
||||
.claim("htm", method) |
||||
.claim("htu", targetUri) |
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
// @formatter:off
|
||||
DPoPProofContext dPoPProofContext = DPoPProofContext.withDPoPProof(dPoPProof.getTokenValue()) |
||||
.method(method) |
||||
.targetUri(targetUri) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
JwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(dPoPProofContext); |
||||
|
||||
assertThatExceptionOfType(BadJwtException.class) |
||||
.isThrownBy(() -> jwtDecoder.decode(dPoPProofContext.getDPoPProof())) |
||||
.withMessageContaining("iat claim is invalid"); |
||||
} |
||||
|
||||
@Test |
||||
public void decodeWhenDPoPProofValidThenDecoded() throws Exception { |
||||
RSAKey rsaJwk = TestJwks.DEFAULT_RSA_JWK; |
||||
given(this.jwkSource.get(any(), any())).willReturn(Collections.singletonList(rsaJwk)); |
||||
|
||||
String method = "GET"; |
||||
String targetUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = rsaJwk.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("dpop+jwt") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(Instant.now()) |
||||
.claim("htm", method) |
||||
.claim("htu", targetUri) |
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
// @formatter:off
|
||||
DPoPProofContext dPoPProofContext = DPoPProofContext.withDPoPProof(dPoPProof.getTokenValue()) |
||||
.method(method) |
||||
.targetUri(targetUri) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
JwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(dPoPProofContext); |
||||
jwtDecoder.decode(dPoPProof.getTokenValue()); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,273 @@
@@ -0,0 +1,273 @@
|
||||
/* |
||||
* 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 java.nio.charset.StandardCharsets; |
||||
import java.security.MessageDigest; |
||||
import java.security.PublicKey; |
||||
import java.time.Instant; |
||||
import java.util.Base64; |
||||
import java.util.Map; |
||||
import java.util.function.Function; |
||||
|
||||
import com.nimbusds.jose.jwk.AsymmetricJWK; |
||||
import com.nimbusds.jose.jwk.JWK; |
||||
|
||||
import org.springframework.security.authentication.AuthenticationManager; |
||||
import org.springframework.security.authentication.AuthenticationProvider; |
||||
import org.springframework.security.core.Authentication; |
||||
import org.springframework.security.core.AuthenticationException; |
||||
import org.springframework.security.oauth2.core.ClaimAccessor; |
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; |
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException; |
||||
import org.springframework.security.oauth2.core.OAuth2Error; |
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes; |
||||
import org.springframework.security.oauth2.core.OAuth2Token; |
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator; |
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; |
||||
import org.springframework.security.oauth2.jwt.DPoPProofContext; |
||||
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory; |
||||
import org.springframework.security.oauth2.jwt.Jwt; |
||||
import org.springframework.security.oauth2.jwt.JwtDecoder; |
||||
import org.springframework.security.oauth2.jwt.JwtDecoderFactory; |
||||
import org.springframework.security.oauth2.jwt.JwtException; |
||||
import org.springframework.util.Assert; |
||||
import org.springframework.util.CollectionUtils; |
||||
import org.springframework.util.StringUtils; |
||||
|
||||
/** |
||||
* @author Joe Grandja |
||||
* @since 6.5 |
||||
* @see DPoPAuthenticationToken |
||||
* @see DPoPProofJwtDecoderFactory |
||||
*/ |
||||
public final class DPoPAuthenticationProvider implements AuthenticationProvider { |
||||
|
||||
private final AuthenticationManager tokenAuthenticationManager; |
||||
|
||||
private JwtDecoderFactory<DPoPProofContext> dPoPProofVerifierFactory; |
||||
|
||||
public DPoPAuthenticationProvider(AuthenticationManager tokenAuthenticationManager) { |
||||
Assert.notNull(tokenAuthenticationManager, "tokenAuthenticationManager cannot be null"); |
||||
this.tokenAuthenticationManager = tokenAuthenticationManager; |
||||
Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = ( |
||||
context) -> new DelegatingOAuth2TokenValidator<>( |
||||
// Use default validators
|
||||
DPoPProofJwtDecoderFactory.DEFAULT_JWT_VALIDATOR_FACTORY.apply(context), |
||||
// Add custom validators
|
||||
new AthClaimValidator(context.getAccessToken()), |
||||
new JwkThumbprintValidator(context.getAccessToken())); |
||||
DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory(); |
||||
dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory); |
||||
this.dPoPProofVerifierFactory = dPoPProofJwtDecoderFactory; |
||||
} |
||||
|
||||
@Override |
||||
public Authentication authenticate(Authentication authentication) throws AuthenticationException { |
||||
DPoPAuthenticationToken dPoPAuthenticationToken = (DPoPAuthenticationToken) authentication; |
||||
|
||||
BearerTokenAuthenticationToken accessTokenAuthenticationRequest = new BearerTokenAuthenticationToken( |
||||
dPoPAuthenticationToken.getAccessToken()); |
||||
Authentication accessTokenAuthenticationResult = this.tokenAuthenticationManager |
||||
.authenticate(accessTokenAuthenticationRequest); |
||||
|
||||
AbstractOAuth2TokenAuthenticationToken<OAuth2Token> accessTokenAuthentication = null; |
||||
if (accessTokenAuthenticationResult instanceof AbstractOAuth2TokenAuthenticationToken) { |
||||
accessTokenAuthentication = (AbstractOAuth2TokenAuthenticationToken) accessTokenAuthenticationResult; |
||||
} |
||||
if (accessTokenAuthentication == null) { |
||||
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, |
||||
"Unable to authenticate the DPoP-bound access token.", null); |
||||
throw new OAuth2AuthenticationException(error); |
||||
} |
||||
|
||||
OAuth2AccessTokenClaims accessToken = new OAuth2AccessTokenClaims(accessTokenAuthentication.getToken(), |
||||
accessTokenAuthentication.getTokenAttributes()); |
||||
|
||||
DPoPProofContext dPoPProofContext = DPoPProofContext.withDPoPProof(dPoPAuthenticationToken.getDPoPProof()) |
||||
.accessToken(accessToken) |
||||
.method(dPoPAuthenticationToken.getMethod()) |
||||
.targetUri(dPoPAuthenticationToken.getResourceUri()) |
||||
.build(); |
||||
JwtDecoder dPoPProofVerifier = this.dPoPProofVerifierFactory.createDecoder(dPoPProofContext); |
||||
|
||||
try { |
||||
dPoPProofVerifier.decode(dPoPProofContext.getDPoPProof()); |
||||
} |
||||
catch (JwtException ex) { |
||||
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF); |
||||
throw new OAuth2AuthenticationException(error, ex); |
||||
} |
||||
|
||||
return accessTokenAuthenticationResult; |
||||
} |
||||
|
||||
@Override |
||||
public boolean supports(Class<?> authentication) { |
||||
return DPoPAuthenticationToken.class.isAssignableFrom(authentication); |
||||
} |
||||
|
||||
public void setDPoPProofVerifierFactory(JwtDecoderFactory<DPoPProofContext> dPoPProofVerifierFactory) { |
||||
Assert.notNull(dPoPProofVerifierFactory, "dPoPProofVerifierFactory cannot be null"); |
||||
this.dPoPProofVerifierFactory = dPoPProofVerifierFactory; |
||||
} |
||||
|
||||
private static final class AthClaimValidator implements OAuth2TokenValidator<Jwt> { |
||||
|
||||
private final OAuth2AccessTokenClaims accessToken; |
||||
|
||||
private AthClaimValidator(OAuth2AccessTokenClaims accessToken) { |
||||
Assert.notNull(accessToken, "accessToken cannot be null"); |
||||
this.accessToken = accessToken; |
||||
} |
||||
|
||||
@Override |
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) { |
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null"); |
||||
String accessTokenHashClaim = jwt.getClaimAsString("ath"); |
||||
if (!StringUtils.hasText(accessTokenHashClaim)) { |
||||
OAuth2Error error = createOAuth2Error("ath claim is required."); |
||||
return OAuth2TokenValidatorResult.failure(error); |
||||
} |
||||
|
||||
String accessTokenHash; |
||||
try { |
||||
accessTokenHash = computeSHA256(this.accessToken.getTokenValue()); |
||||
} |
||||
catch (Exception ex) { |
||||
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token."); |
||||
return OAuth2TokenValidatorResult.failure(error); |
||||
} |
||||
if (!accessTokenHashClaim.equals(accessTokenHash)) { |
||||
OAuth2Error error = createOAuth2Error("ath claim is invalid."); |
||||
return OAuth2TokenValidatorResult.failure(error); |
||||
} |
||||
return OAuth2TokenValidatorResult.success(); |
||||
} |
||||
|
||||
private static OAuth2Error createOAuth2Error(String reason) { |
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null); |
||||
} |
||||
|
||||
private static String computeSHA256(String value) throws Exception { |
||||
MessageDigest md = MessageDigest.getInstance("SHA-256"); |
||||
byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8)); |
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); |
||||
} |
||||
|
||||
} |
||||
|
||||
private static final class JwkThumbprintValidator implements OAuth2TokenValidator<Jwt> { |
||||
|
||||
private final OAuth2AccessTokenClaims accessToken; |
||||
|
||||
private JwkThumbprintValidator(OAuth2AccessTokenClaims accessToken) { |
||||
Assert.notNull(accessToken, "accessToken cannot be null"); |
||||
this.accessToken = accessToken; |
||||
} |
||||
|
||||
@Override |
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) { |
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null"); |
||||
String jwkThumbprintClaim = null; |
||||
Map<String, Object> confirmationMethodClaim = this.accessToken.getClaimAsMap("cnf"); |
||||
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) { |
||||
jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt"); |
||||
} |
||||
if (jwkThumbprintClaim == null) { |
||||
OAuth2Error error = createOAuth2Error("jkt claim is required."); |
||||
return OAuth2TokenValidatorResult.failure(error); |
||||
} |
||||
|
||||
PublicKey publicKey = null; |
||||
@SuppressWarnings("unchecked") |
||||
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk"); |
||||
try { |
||||
JWK jwk = JWK.parse(jwkJson); |
||||
if (jwk instanceof AsymmetricJWK) { |
||||
publicKey = ((AsymmetricJWK) jwk).toPublicKey(); |
||||
} |
||||
} |
||||
catch (Exception ignored) { |
||||
} |
||||
if (publicKey == null) { |
||||
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid."); |
||||
return OAuth2TokenValidatorResult.failure(error); |
||||
} |
||||
|
||||
String jwkThumbprint; |
||||
try { |
||||
jwkThumbprint = computeSHA256(publicKey); |
||||
} |
||||
catch (Exception ex) { |
||||
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk."); |
||||
return OAuth2TokenValidatorResult.failure(error); |
||||
} |
||||
|
||||
if (!jwkThumbprintClaim.equals(jwkThumbprint)) { |
||||
OAuth2Error error = createOAuth2Error("jkt claim is invalid."); |
||||
return OAuth2TokenValidatorResult.failure(error); |
||||
} |
||||
return OAuth2TokenValidatorResult.success(); |
||||
} |
||||
|
||||
private static OAuth2Error createOAuth2Error(String reason) { |
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null); |
||||
} |
||||
|
||||
private static String computeSHA256(PublicKey publicKey) throws Exception { |
||||
MessageDigest md = MessageDigest.getInstance("SHA-256"); |
||||
byte[] digest = md.digest(publicKey.getEncoded()); |
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); |
||||
} |
||||
|
||||
} |
||||
|
||||
private static final class OAuth2AccessTokenClaims implements OAuth2Token, ClaimAccessor { |
||||
|
||||
private final OAuth2Token accessToken; |
||||
|
||||
private final Map<String, Object> claims; |
||||
|
||||
private OAuth2AccessTokenClaims(OAuth2Token accessToken, Map<String, Object> claims) { |
||||
this.accessToken = accessToken; |
||||
this.claims = claims; |
||||
} |
||||
|
||||
@Override |
||||
public String getTokenValue() { |
||||
return this.accessToken.getTokenValue(); |
||||
} |
||||
|
||||
@Override |
||||
public Instant getIssuedAt() { |
||||
return this.accessToken.getIssuedAt(); |
||||
} |
||||
|
||||
@Override |
||||
public Instant getExpiresAt() { |
||||
return this.accessToken.getExpiresAt(); |
||||
} |
||||
|
||||
@Override |
||||
public Map<String, Object> getClaims() { |
||||
return this.claims; |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
|
||||
/* |
||||
* 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 java.io.Serial; |
||||
import java.util.Collections; |
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken; |
||||
import org.springframework.util.Assert; |
||||
|
||||
/** |
||||
* @author Joe Grandja |
||||
* @since 6.5 |
||||
* @see DPoPAuthenticationProvider |
||||
*/ |
||||
public class DPoPAuthenticationToken extends AbstractAuthenticationToken { |
||||
|
||||
@Serial |
||||
private static final long serialVersionUID = 5481690438914686216L; |
||||
|
||||
private final String accessToken; |
||||
|
||||
private final String dPoPProof; |
||||
|
||||
private final String method; |
||||
|
||||
private final String resourceUri; |
||||
|
||||
public DPoPAuthenticationToken(String accessToken, String dPoPProof, String method, String resourceUri) { |
||||
super(Collections.emptyList()); |
||||
Assert.hasText(accessToken, "accessToken cannot be empty"); |
||||
Assert.hasText(dPoPProof, "dPoPProof cannot be empty"); |
||||
Assert.hasText(method, "method cannot be empty"); |
||||
Assert.hasText(resourceUri, "resourceUri cannot be empty"); |
||||
this.accessToken = accessToken; |
||||
this.dPoPProof = dPoPProof; |
||||
this.method = method; |
||||
this.resourceUri = resourceUri; |
||||
} |
||||
|
||||
@Override |
||||
public Object getPrincipal() { |
||||
return getAccessToken(); |
||||
} |
||||
|
||||
@Override |
||||
public Object getCredentials() { |
||||
return getAccessToken(); |
||||
} |
||||
|
||||
public String getAccessToken() { |
||||
return this.accessToken; |
||||
} |
||||
|
||||
public String getDPoPProof() { |
||||
return this.dPoPProof; |
||||
} |
||||
|
||||
public String getMethod() { |
||||
return this.method; |
||||
} |
||||
|
||||
public String getResourceUri() { |
||||
return this.resourceUri; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,331 @@
@@ -0,0 +1,331 @@
|
||||
/* |
||||
* 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 java.nio.charset.StandardCharsets; |
||||
import java.security.MessageDigest; |
||||
import java.security.PublicKey; |
||||
import java.time.Instant; |
||||
import java.time.temporal.ChronoUnit; |
||||
import java.util.Base64; |
||||
import java.util.HashMap; |
||||
import java.util.Map; |
||||
import java.util.UUID; |
||||
|
||||
import com.nimbusds.jose.jwk.JWKSet; |
||||
import com.nimbusds.jose.jwk.source.JWKSource; |
||||
import com.nimbusds.jose.proc.SecurityContext; |
||||
import org.junit.jupiter.api.BeforeEach; |
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import org.springframework.security.authentication.AuthenticationManager; |
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException; |
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes; |
||||
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.JwsHeader; |
||||
import org.springframework.security.oauth2.jwt.Jwt; |
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet; |
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters; |
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; |
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType; |
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; |
||||
import static org.mockito.ArgumentMatchers.any; |
||||
import static org.mockito.BDDMockito.given; |
||||
import static org.mockito.Mockito.mock; |
||||
|
||||
/** |
||||
* Tests for {@link DPoPAuthenticationProvider}. |
||||
* |
||||
* @author Joe Grandja |
||||
*/ |
||||
public class DPoPAuthenticationProviderTests { |
||||
|
||||
private NimbusJwtEncoder accessTokenJwtEncoder; |
||||
|
||||
private NimbusJwtEncoder dPoPProofJwtEncoder; |
||||
|
||||
private AuthenticationManager tokenAuthenticationManager; |
||||
|
||||
private DPoPAuthenticationProvider authenticationProvider; |
||||
|
||||
@BeforeEach |
||||
public void setUp() { |
||||
JWKSource<SecurityContext> jwkSource = (jwkSelector, securityContext) -> jwkSelector |
||||
.select(new JWKSet(TestJwks.DEFAULT_EC_JWK)); |
||||
this.accessTokenJwtEncoder = new NimbusJwtEncoder(jwkSource); |
||||
jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(new JWKSet(TestJwks.DEFAULT_RSA_JWK)); |
||||
this.dPoPProofJwtEncoder = new NimbusJwtEncoder(jwkSource); |
||||
this.tokenAuthenticationManager = mock(AuthenticationManager.class); |
||||
this.authenticationProvider = new DPoPAuthenticationProvider(this.tokenAuthenticationManager); |
||||
} |
||||
|
||||
@Test |
||||
public void constructorWhenTokenAuthenticationManagerNullThenThrowIllegalArgumentException() { |
||||
assertThatIllegalArgumentException().isThrownBy(() -> new DPoPAuthenticationProvider(null)) |
||||
.withMessage("tokenAuthenticationManager cannot be null"); |
||||
} |
||||
|
||||
@Test |
||||
public void supportsWhenDPoPAuthenticationTokenThenReturnsTrue() { |
||||
assertThat(this.authenticationProvider.supports(DPoPAuthenticationToken.class)).isTrue(); |
||||
} |
||||
|
||||
@Test |
||||
public void setDPoPProofVerifierFactoryWhenNullThenThrowIllegalArgumentException() { |
||||
assertThatIllegalArgumentException() |
||||
.isThrownBy(() -> this.authenticationProvider.setDPoPProofVerifierFactory(null)) |
||||
.withMessage("dPoPProofVerifierFactory cannot be null"); |
||||
} |
||||
|
||||
@Test |
||||
public void authenticateWhenUnableToAuthenticateAccessTokenThenThrowOAuth2AuthenticationException() { |
||||
DPoPAuthenticationToken dPoPAuthenticationToken = new DPoPAuthenticationToken("access-token", "dpop-proof", |
||||
"GET", "https://resource1"); |
||||
assertThatExceptionOfType(OAuth2AuthenticationException.class) |
||||
.isThrownBy(() -> this.authenticationProvider.authenticate(dPoPAuthenticationToken)) |
||||
.satisfies((ex) -> { |
||||
assertThat(ex.getError().getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN); |
||||
assertThat(ex.getError().getDescription()) |
||||
.isEqualTo("Unable to authenticate the DPoP-bound access token."); |
||||
}); |
||||
} |
||||
|
||||
@Test |
||||
public void authenticateWhenAthMissingThenThrowOAuth2AuthenticationException() { |
||||
Jwt accessToken = generateAccessToken(); |
||||
JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(accessToken); |
||||
given(this.tokenAuthenticationManager.authenticate(any())).willReturn(jwtAuthenticationToken); |
||||
|
||||
String method = "GET"; |
||||
String resourceUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = TestJwks.DEFAULT_RSA_JWK.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("dpop+jwt") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(Instant.now()) |
||||
.claim("htm", method) |
||||
.claim("htu", resourceUri) |
||||
// .claim("ath", computeSHA256(accessToken.getTokenValue()))
|
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
DPoPAuthenticationToken dPoPAuthenticationToken = new DPoPAuthenticationToken(accessToken.getTokenValue(), |
||||
dPoPProof.getTokenValue(), method, resourceUri); |
||||
assertThatExceptionOfType(OAuth2AuthenticationException.class) |
||||
.isThrownBy(() -> this.authenticationProvider.authenticate(dPoPAuthenticationToken)) |
||||
.satisfies((ex) -> { |
||||
assertThat(ex.getError().getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_DPOP_PROOF); |
||||
assertThat(ex.getMessage()).contains("ath claim is required"); |
||||
}); |
||||
} |
||||
|
||||
@Test |
||||
public void authenticateWhenAthDoesNotMatchThenThrowOAuth2AuthenticationException() throws Exception { |
||||
Jwt accessToken = generateAccessToken(); |
||||
JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(accessToken); |
||||
given(this.tokenAuthenticationManager.authenticate(any())).willReturn(jwtAuthenticationToken); |
||||
|
||||
String method = "GET"; |
||||
String resourceUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = TestJwks.DEFAULT_RSA_JWK.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("dpop+jwt") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(Instant.now()) |
||||
.claim("htm", method) |
||||
.claim("htu", resourceUri) |
||||
.claim("ath", computeSHA256(accessToken.getTokenValue()) + "-mismatch") |
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
DPoPAuthenticationToken dPoPAuthenticationToken = new DPoPAuthenticationToken(accessToken.getTokenValue(), |
||||
dPoPProof.getTokenValue(), method, resourceUri); |
||||
assertThatExceptionOfType(OAuth2AuthenticationException.class) |
||||
.isThrownBy(() -> this.authenticationProvider.authenticate(dPoPAuthenticationToken)) |
||||
.satisfies((ex) -> { |
||||
assertThat(ex.getError().getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_DPOP_PROOF); |
||||
assertThat(ex.getMessage()).contains("ath claim is invalid"); |
||||
}); |
||||
} |
||||
|
||||
@Test |
||||
public void authenticateWhenJktMissingThenThrowOAuth2AuthenticationException() throws Exception { |
||||
Jwt accessToken = generateAccessToken(null); // jkt claim is not added
|
||||
JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(accessToken); |
||||
given(this.tokenAuthenticationManager.authenticate(any())).willReturn(jwtAuthenticationToken); |
||||
|
||||
String method = "GET"; |
||||
String resourceUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = TestJwks.DEFAULT_RSA_JWK.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("dpop+jwt") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(Instant.now()) |
||||
.claim("htm", method) |
||||
.claim("htu", resourceUri) |
||||
.claim("ath", computeSHA256(accessToken.getTokenValue())) |
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
DPoPAuthenticationToken dPoPAuthenticationToken = new DPoPAuthenticationToken(accessToken.getTokenValue(), |
||||
dPoPProof.getTokenValue(), method, resourceUri); |
||||
assertThatExceptionOfType(OAuth2AuthenticationException.class) |
||||
.isThrownBy(() -> this.authenticationProvider.authenticate(dPoPAuthenticationToken)) |
||||
.satisfies((ex) -> { |
||||
assertThat(ex.getError().getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_DPOP_PROOF); |
||||
assertThat(ex.getMessage()).contains("jkt claim is required"); |
||||
}); |
||||
} |
||||
|
||||
@Test |
||||
public void authenticateWhenJktDoesNotMatchThenThrowOAuth2AuthenticationException() throws Exception { |
||||
// Use different client public key
|
||||
Jwt accessToken = generateAccessToken(TestKeys.DEFAULT_EC_KEY_PAIR.getPublic()); |
||||
JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(accessToken); |
||||
given(this.tokenAuthenticationManager.authenticate(any())).willReturn(jwtAuthenticationToken); |
||||
|
||||
String method = "GET"; |
||||
String resourceUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = TestJwks.DEFAULT_RSA_JWK.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("dpop+jwt") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(Instant.now()) |
||||
.claim("htm", method) |
||||
.claim("htu", resourceUri) |
||||
.claim("ath", computeSHA256(accessToken.getTokenValue())) |
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
DPoPAuthenticationToken dPoPAuthenticationToken = new DPoPAuthenticationToken(accessToken.getTokenValue(), |
||||
dPoPProof.getTokenValue(), method, resourceUri); |
||||
assertThatExceptionOfType(OAuth2AuthenticationException.class) |
||||
.isThrownBy(() -> this.authenticationProvider.authenticate(dPoPAuthenticationToken)) |
||||
.satisfies((ex) -> { |
||||
assertThat(ex.getError().getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_DPOP_PROOF); |
||||
assertThat(ex.getMessage()).contains("jkt claim is invalid"); |
||||
}); |
||||
} |
||||
|
||||
@Test |
||||
public void authenticateWhenDPoPProofValidThenSuccess() throws Exception { |
||||
Jwt accessToken = generateAccessToken(); |
||||
JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(accessToken); |
||||
given(this.tokenAuthenticationManager.authenticate(any())).willReturn(jwtAuthenticationToken); |
||||
|
||||
String method = "GET"; |
||||
String resourceUri = "https://resource1"; |
||||
|
||||
// @formatter:off
|
||||
Map<String, Object> publicJwk = TestJwks.DEFAULT_RSA_JWK.toPublicJWK().toJSONObject(); |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256) |
||||
.type("dpop+jwt") |
||||
.jwk(publicJwk) |
||||
.build(); |
||||
JwtClaimsSet claims = JwtClaimsSet.builder() |
||||
.issuedAt(Instant.now()) |
||||
.claim("htm", method) |
||||
.claim("htu", resourceUri) |
||||
.claim("ath", computeSHA256(accessToken.getTokenValue())) |
||||
.id(UUID.randomUUID().toString()) |
||||
.build(); |
||||
// @formatter:on
|
||||
|
||||
Jwt dPoPProof = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims)); |
||||
|
||||
DPoPAuthenticationToken dPoPAuthenticationToken = new DPoPAuthenticationToken(accessToken.getTokenValue(), |
||||
dPoPProof.getTokenValue(), method, resourceUri); |
||||
assertThat(this.authenticationProvider.authenticate(dPoPAuthenticationToken)).isSameAs(jwtAuthenticationToken); |
||||
} |
||||
|
||||
private Jwt generateAccessToken() { |
||||
return generateAccessToken(TestKeys.DEFAULT_PUBLIC_KEY); |
||||
} |
||||
|
||||
private Jwt generateAccessToken(PublicKey clientPublicKey) { |
||||
Map<String, Object> jktClaim = null; |
||||
if (clientPublicKey != null) { |
||||
try { |
||||
String sha256Thumbprint = computeSHA256(clientPublicKey); |
||||
jktClaim = new HashMap<>(); |
||||
jktClaim.put("jkt", sha256Thumbprint); |
||||
} |
||||
catch (Exception ignored) { |
||||
} |
||||
} |
||||
JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256).build(); |
||||
Instant issuedAt = Instant.now(); |
||||
Instant expiresAt = issuedAt.plus(30, ChronoUnit.MINUTES); |
||||
// @formatter:off
|
||||
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() |
||||
.issuer("https://provider.com") |
||||
.subject("subject") |
||||
.issuedAt(issuedAt) |
||||
.expiresAt(expiresAt) |
||||
.id(UUID.randomUUID().toString()); |
||||
if (jktClaim != null) { |
||||
claimsBuilder.claim("cnf", jktClaim); // Bind client public key
|
||||
} |
||||
// @formatter:on
|
||||
return this.accessTokenJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claimsBuilder.build())); |
||||
} |
||||
|
||||
private static String computeSHA256(String value) throws Exception { |
||||
MessageDigest md = MessageDigest.getInstance("SHA-256"); |
||||
byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8)); |
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); |
||||
} |
||||
|
||||
private static String computeSHA256(PublicKey publicKey) throws Exception { |
||||
MessageDigest md = MessageDigest.getInstance("SHA-256"); |
||||
byte[] digest = md.digest(publicKey.getEncoded()); |
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue