Add authorization server metadata for DPoP support

Issue gh-1813

Closes gh-1951
This commit is contained in:
Joe Grandja
2025-03-26 05:22:44 -04:00
parent 48fd6ab60f
commit 65e3a5ec9b
9 changed files with 160 additions and 10 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2024 the original author or authors.
* Copyright 2020-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,6 +25,7 @@ import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion;
import org.springframework.util.Assert;
@@ -48,6 +49,9 @@ import org.springframework.util.Assert;
* @see <a target="_blank" href=
* "https://datatracker.ietf.org/doc/html/rfc8705#section-3.3">3.3 Mutual-TLS Client
* Certificate-Bound Access Tokens Metadata</a>
* @see <a target="_blank" href=
* "https://datatracker.ietf.org/doc/html/rfc9449#section-5.1">5.1 OAuth 2.0 Demonstrating
* Proof of Possession (DPoP) Metadata</a>
*/
public abstract class AbstractOAuth2AuthorizationServerMetadata
implements OAuth2AuthorizationServerMetadataClaimAccessor, Serializable {
@@ -379,6 +383,37 @@ public abstract class AbstractOAuth2AuthorizationServerMetadata
tlsClientCertificateBoundAccessTokens);
}
/**
* Add a {@link JwsAlgorithms JSON Web Signature (JWS) algorithm} to the
* collection of {@code dpop_signing_alg_values_supported} in the resulting
* {@link AbstractOAuth2AuthorizationServerMetadata}, OPTIONAL.
* @param dPoPSigningAlgorithm the {@link JwsAlgorithms JSON Web Signature (JWS)
* algorithm} supported for DPoP Proof JWTs
* @return the {@link AbstractBuilder} for further configuration
* @since 1.5
*/
public B dPoPSigningAlgorithm(String dPoPSigningAlgorithm) {
addClaimToClaimList(OAuth2AuthorizationServerMetadataClaimNames.DPOP_SIGNING_ALG_VALUES_SUPPORTED,
dPoPSigningAlgorithm);
return getThis();
}
/**
* A {@code Consumer} of the {@link JwsAlgorithms JSON Web Signature (JWS)
* algorithms} supported for DPoP Proof JWTs allowing the ability to add, replace,
* or remove.
* @param dPoPSigningAlgorithmsConsumer a {@code Consumer} of the
* {@link JwsAlgorithms JSON Web Signature (JWS) algorithms} supported for DPoP
* Proof JWTs
* @return the {@link AbstractBuilder} for further configuration
* @since 1.5
*/
public B dPoPSigningAlgorithms(Consumer<List<String>> dPoPSigningAlgorithmsConsumer) {
acceptClaimValues(OAuth2AuthorizationServerMetadataClaimNames.DPOP_SIGNING_ALG_VALUES_SUPPORTED,
dPoPSigningAlgorithmsConsumer);
return getThis();
}
/**
* Use this claim in the resulting
* {@link AbstractOAuth2AuthorizationServerMetadata}.
@@ -506,6 +541,16 @@ public abstract class AbstractOAuth2AuthorizationServerMetadata
.get(OAuth2AuthorizationServerMetadataClaimNames.CODE_CHALLENGE_METHODS_SUPPORTED),
"codeChallengeMethods cannot be empty");
}
if (getClaims()
.get(OAuth2AuthorizationServerMetadataClaimNames.DPOP_SIGNING_ALG_VALUES_SUPPORTED) != null) {
Assert.isInstanceOf(List.class,
getClaims().get(OAuth2AuthorizationServerMetadataClaimNames.DPOP_SIGNING_ALG_VALUES_SUPPORTED),
"dPoPSigningAlgorithms must be of type List");
Assert.notEmpty(
(List<?>) getClaims()
.get(OAuth2AuthorizationServerMetadataClaimNames.DPOP_SIGNING_ALG_VALUES_SUPPORTED),
"dPoPSigningAlgorithms cannot be empty");
}
}
@SuppressWarnings("unchecked")

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2024 the original author or authors.
* Copyright 2020-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.
@@ -19,6 +19,7 @@ import java.net.URL;
import java.util.List;
import org.springframework.security.oauth2.core.ClaimAccessor;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
/**
* A {@link ClaimAccessor} for the "claims" an Authorization Server describes about its
@@ -40,6 +41,9 @@ import org.springframework.security.oauth2.core.ClaimAccessor;
* @see <a target="_blank" href=
* "https://datatracker.ietf.org/doc/html/rfc8705#section-3.3">3.3 Mutual-TLS Client
* Certificate-Bound Access Tokens Metadata</a>
* @see <a target="_blank" href=
* "https://datatracker.ietf.org/doc/html/rfc9449#section-5.1">5.1 OAuth 2.0 Demonstrating
* Proof of Possession (DPoP) Metadata</a>
*/
public interface OAuth2AuthorizationServerMetadataClaimAccessor extends ClaimAccessor {
@@ -193,4 +197,15 @@ public interface OAuth2AuthorizationServerMetadataClaimAccessor extends ClaimAcc
OAuth2AuthorizationServerMetadataClaimNames.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS));
}
/**
* Returns the {@link JwsAlgorithms JSON Web Signature (JWS) algorithms} supported for
* DPoP Proof JWTs {@code (dpop_signing_alg_values_supported)}.
* @return the {@link JwsAlgorithms JSON Web Signature (JWS) algorithms} supported for
* DPoP Proof JWTs
* @since 1.5
*/
default List<String> getDPoPSigningAlgorithms() {
return getClaimAsStringList(OAuth2AuthorizationServerMetadataClaimNames.DPOP_SIGNING_ALG_VALUES_SUPPORTED);
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2024 the original author or authors.
* Copyright 2020-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.
@@ -15,6 +15,8 @@
*/
package org.springframework.security.oauth2.server.authorization;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
/**
* The names of the "claims" an Authorization Server describes about its configuration,
* used in OAuth 2.0 Authorization Server Metadata and OpenID Connect Discovery 1.0.
@@ -32,6 +34,9 @@ package org.springframework.security.oauth2.server.authorization;
* @see <a target="_blank" href=
* "https://datatracker.ietf.org/doc/html/rfc8705#section-3.3">3.3 Mutual-TLS Client
* Certificate-Bound Access Tokens Metadata</a>
* @see <a target="_blank" href=
* "https://datatracker.ietf.org/doc/html/rfc9449#section-5.1">5.1 OAuth 2.0 Demonstrating
* Proof of Possession (DPoP) Metadata</a>
*/
public class OAuth2AuthorizationServerMetadataClaimNames {
@@ -130,6 +135,13 @@ public class OAuth2AuthorizationServerMetadataClaimNames {
*/
public static final String TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS = "tls_client_certificate_bound_access_tokens";
/**
* {@code dpop_signing_alg_values_supported} - the {@link JwsAlgorithms JSON Web
* Signature (JWS) algorithms} supported for DPoP Proof JWTs
* @since 1.5
*/
public static final String DPOP_SIGNING_ALG_VALUES_SUPPORTED = "dpop_signing_alg_values_supported";
protected OAuth2AuthorizationServerMetadataClaimNames() {
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2024 the original author or authors.
* Copyright 2020-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.
@@ -169,6 +169,8 @@ public class OAuth2AuthorizationServerMetadataHttpMessageConverter
collectionStringConverter);
claimConverters.put(OAuth2AuthorizationServerMetadataClaimNames.CODE_CHALLENGE_METHODS_SUPPORTED,
collectionStringConverter);
claimConverters.put(OAuth2AuthorizationServerMetadataClaimNames.DPOP_SIGNING_ALG_VALUES_SUPPORTED,
collectionStringConverter);
this.claimTypeConverter = new ClaimTypeConverter(claimConverters);
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2024 the original author or authors.
* Copyright 2020-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.
@@ -31,6 +31,7 @@ import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContext;
import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
@@ -118,6 +119,7 @@ public final class OidcProviderConfigurationEndpointFilter extends OncePerReques
.tokenIntrospectionEndpointAuthenticationMethods(clientAuthenticationMethods())
.codeChallengeMethod("S256")
.tlsClientCertificateBoundAccessTokens(true)
.dPoPSigningAlgorithms(dPoPSigningAlgorithms())
.subjectType("public")
.idTokenSigningAlgorithm(SignatureAlgorithm.RS256.getName())
.scope(OidcScopes.OPENID);
@@ -151,6 +153,20 @@ public final class OidcProviderConfigurationEndpointFilter extends OncePerReques
};
}
private static Consumer<List<String>> dPoPSigningAlgorithms() {
return (algs) -> {
algs.add(JwsAlgorithms.RS256);
algs.add(JwsAlgorithms.RS384);
algs.add(JwsAlgorithms.RS512);
algs.add(JwsAlgorithms.PS256);
algs.add(JwsAlgorithms.PS384);
algs.add(JwsAlgorithms.PS512);
algs.add(JwsAlgorithms.ES256);
algs.add(JwsAlgorithms.ES384);
algs.add(JwsAlgorithms.ES512);
};
}
private static String asUrl(String issuer, String endpoint) {
return UriComponentsBuilder.fromUriString(issuer).path(endpoint).build().toUriString();
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2024 the original author or authors.
* Copyright 2020-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.
@@ -30,6 +30,7 @@ import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationServerMetadata;
import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContext;
import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
@@ -115,7 +116,8 @@ public final class OAuth2AuthorizationServerMetadataEndpointFilter extends OnceP
.tokenIntrospectionEndpoint(asUrl(issuer, authorizationServerSettings.getTokenIntrospectionEndpoint()))
.tokenIntrospectionEndpointAuthenticationMethods(clientAuthenticationMethods())
.codeChallengeMethod("S256")
.tlsClientCertificateBoundAccessTokens(true);
.tlsClientCertificateBoundAccessTokens(true)
.dPoPSigningAlgorithms(dPoPSigningAlgorithms());
this.authorizationServerMetadataCustomizer.accept(authorizationServerMetadata);
@@ -146,6 +148,20 @@ public final class OAuth2AuthorizationServerMetadataEndpointFilter extends OnceP
};
}
private static Consumer<List<String>> dPoPSigningAlgorithms() {
return (algs) -> {
algs.add(JwsAlgorithms.RS256);
algs.add(JwsAlgorithms.RS384);
algs.add(JwsAlgorithms.RS512);
algs.add(JwsAlgorithms.PS256);
algs.add(JwsAlgorithms.PS384);
algs.add(JwsAlgorithms.PS512);
algs.add(JwsAlgorithms.ES256);
algs.add(JwsAlgorithms.ES384);
algs.add(JwsAlgorithms.ES512);
};
}
private static String asUrl(String issuer, String endpoint) {
return UriComponentsBuilder.fromUriString(issuer).path(endpoint).toUriString();
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2024 the original author or authors.
* Copyright 2020-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.
@@ -24,6 +24,7 @@ import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationServerMetadata.Builder;
import static org.assertj.core.api.Assertions.assertThat;
@@ -63,6 +64,8 @@ public class OAuth2AuthorizationServerMetadataTests {
.tokenIntrospectionEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
.codeChallengeMethod("S256")
.tlsClientCertificateBoundAccessTokens(true)
.dPoPSigningAlgorithm(JwsAlgorithms.RS256)
.dPoPSigningAlgorithm(JwsAlgorithms.ES256)
.claim("a-claim", "a-value")
.build();
@@ -87,6 +90,8 @@ public class OAuth2AuthorizationServerMetadataTests {
.containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue());
assertThat(authorizationServerMetadata.getCodeChallengeMethods()).containsExactly("S256");
assertThat(authorizationServerMetadata.isTlsClientCertificateBoundAccessTokens()).isTrue();
assertThat(authorizationServerMetadata.getDPoPSigningAlgorithms()).containsExactly(JwsAlgorithms.RS256,
JwsAlgorithms.ES256);
assertThat(authorizationServerMetadata.getClaimAsString("a-claim")).isEqualTo("a-value");
}
@@ -113,6 +118,7 @@ public class OAuth2AuthorizationServerMetadataTests {
assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint()).isNull();
assertThat(authorizationServerMetadata.getTokenIntrospectionEndpointAuthenticationMethods()).isNull();
assertThat(authorizationServerMetadata.getCodeChallengeMethods()).isNull();
assertThat(authorizationServerMetadata.getDPoPSigningAlgorithms()).isNull();
}
@Test
@@ -152,6 +158,7 @@ public class OAuth2AuthorizationServerMetadataTests {
.isEqualTo(url("https://example.com/oauth2/introspect"));
assertThat(authorizationServerMetadata.getTokenIntrospectionEndpointAuthenticationMethods()).isNull();
assertThat(authorizationServerMetadata.getCodeChallengeMethods()).isNull();
assertThat(authorizationServerMetadata.getDPoPSigningAlgorithms()).isNull();
assertThat(authorizationServerMetadata.getClaimAsString("some-claim")).isEqualTo("some-value");
}
@@ -191,6 +198,7 @@ public class OAuth2AuthorizationServerMetadataTests {
.isEqualTo(url("https://example.com/oauth2/introspect"));
assertThat(authorizationServerMetadata.getTokenIntrospectionEndpointAuthenticationMethods()).isNull();
assertThat(authorizationServerMetadata.getCodeChallengeMethods()).isNull();
assertThat(authorizationServerMetadata.getDPoPSigningAlgorithms()).isNull();
assertThat(authorizationServerMetadata.getClaimAsString("some-claim")).isEqualTo("some-value");
}
@@ -536,6 +544,38 @@ public class OAuth2AuthorizationServerMetadataTests {
assertThat(authorizationServerMetadata.getCodeChallengeMethods()).containsExactly("some-authentication-method");
}
@Test
public void buildWhenDPoPSigningAlgorithmsNotListThenThrowIllegalArgumentException() {
Builder builder = this.minimalBuilder.claims((claims) -> claims
.put(OAuth2AuthorizationServerMetadataClaimNames.DPOP_SIGNING_ALG_VALUES_SUPPORTED, "not-a-list"));
assertThatIllegalArgumentException().isThrownBy(builder::build)
.withMessageStartingWith("dPoPSigningAlgorithms must be of type List");
}
@Test
public void buildWhenDPoPSigningAlgorithmsEmptyListThenThrowIllegalArgumentException() {
Builder builder = this.minimalBuilder.claims(
(claims) -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.DPOP_SIGNING_ALG_VALUES_SUPPORTED,
Collections.emptyList()));
assertThatIllegalArgumentException().isThrownBy(builder::build)
.withMessage("dPoPSigningAlgorithms cannot be empty");
}
@Test
public void buildWhenDPoPSigningAlgorithmsAddingOrRemovingThenCorrectValues() {
OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.minimalBuilder
.dPoPSigningAlgorithm(JwsAlgorithms.RS256)
.dPoPSigningAlgorithms((algs) -> {
algs.clear();
algs.add(JwsAlgorithms.ES256);
})
.build();
assertThat(authorizationServerMetadata.getDPoPSigningAlgorithms()).containsExactly(JwsAlgorithms.ES256);
}
@Test
public void claimWhenNameNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException()

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2024 the original author or authors.
* Copyright 2020-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.
@@ -148,6 +148,8 @@ public class OidcProviderConfigurationEndpointFilterTests {
"\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]");
assertThat(providerConfigurationResponse).contains("\"code_challenge_methods_supported\":[\"S256\"]");
assertThat(providerConfigurationResponse).contains("\"tls_client_certificate_bound_access_tokens\":true");
assertThat(providerConfigurationResponse).contains(
"\"dpop_signing_alg_values_supported\":[\"RS256\",\"RS384\",\"RS512\",\"PS256\",\"PS384\",\"PS512\",\"ES256\",\"ES384\",\"ES512\"]");
assertThat(providerConfigurationResponse).contains("\"subject_types_supported\":[\"public\"]");
assertThat(providerConfigurationResponse).contains("\"id_token_signing_alg_values_supported\":[\"RS256\"]");
assertThat(providerConfigurationResponse).contains("\"userinfo_endpoint\":\"https://example.com/userinfo\"");

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2024 the original author or authors.
* Copyright 2020-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.
@@ -145,6 +145,8 @@ public class OAuth2AuthorizationServerMetadataEndpointFilterTests {
"\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]");
assertThat(authorizationServerMetadataResponse).contains("\"code_challenge_methods_supported\":[\"S256\"]");
assertThat(authorizationServerMetadataResponse).contains("\"tls_client_certificate_bound_access_tokens\":true");
assertThat(authorizationServerMetadataResponse).contains(
"\"dpop_signing_alg_values_supported\":[\"RS256\",\"RS384\",\"RS512\",\"PS256\",\"PS384\",\"PS512\",\"ES256\",\"ES384\",\"ES512\"]");
}
@Test