From 0a4775423b2853ae89d1f53424477b07b90e9fac Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Tue, 10 Nov 2020 11:59:00 +0100 Subject: [PATCH] Implement OAuth 2.0 Server Metadata (RFC 8414) See See https://tools.ietf.org/html/rfc8414 Closes gh-54 --- .../OAuth2AuthorizationServerConfigurer.java | 11 +- ...Auth2AuthorizationServerConfiguration.java | 370 ++++++++++++++ ...horizationServerMetadataClaimAccessor.java | 134 ++++++ ...AuthorizationServerMetadataClaimNames.java | 87 ++++ ...Auth2AuthorizationServerConfiguration.java | 92 ++++ .../endpoint/PkceCodeChallengeMethod2.java | 82 ++++ ...rverConfigurationHttpMessageConverter.java | 162 +++++++ .../core/oidc/OidcProviderConfiguration.java | 211 +------- .../OidcProviderMetadataClaimAccessor.java | 78 +-- .../oidc/OidcProviderMetadataClaimNames.java | 43 +- .../OAuth2ClientAuthenticationProvider.java | 5 +- .../config/ProviderSettings.java | 6 +- ...dcProviderConfigurationEndpointFilter.java | 11 +- .../OAuth2AuthorizationEndpointFilter.java | 3 +- ...tionServerConfigurationEndpointFilter.java | 104 ++++ ...AuthorizationServerConfigurationTests.java | 91 ++++ ...AuthorizationServerConfigurationTests.java | 451 ++++++++++++++++++ .../PkceCodeChallengeMethod2Test.java | 41 ++ ...onfigurationHttpMessageConverterTests.java | 218 +++++++++ .../oidc/OidcProviderConfigurationTests.java | 138 +++--- ...onfigurationHttpMessageConverterTests.java | 9 +- .../config/ProviderSettingsTests.java | 38 +- ...viderConfigurationEndpointFilterTests.java | 14 +- ...erverConfigurationEndpointFilterTests.java | 141 ++++++ 24 files changed, 2123 insertions(+), 417 deletions(-) create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/AbstractOAuth2AuthorizationServerConfiguration.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthorizationServerMetadataClaimAccessor.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthorizationServerMetadataClaimNames.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationServerConfiguration.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/endpoint/PkceCodeChallengeMethod2.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2AuthorizationServerConfigurationHttpMessageConverter.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerConfigurationEndpointFilter.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurationTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationServerConfigurationTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/endpoint/PkceCodeChallengeMethod2Test.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2AuthorizationServerConfigurationHttpMessageConverterTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerConfigurationEndpointFilterTests.java diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java index baeb68491..736cc4796 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java @@ -49,6 +49,7 @@ import org.springframework.security.oauth2.server.authorization.oidc.web.OidcProviderConfigurationEndpointFilter; import org.springframework.security.oauth2.server.authorization.web.NimbusJwkSetEndpointFilter; import org.springframework.security.oauth2.server.authorization.web.OAuth2AuthorizationEndpointFilter; +import org.springframework.security.oauth2.server.authorization.web.OAuth2AuthorizationServerConfigurationEndpointFilter; import org.springframework.security.oauth2.server.authorization.web.OAuth2ClientAuthenticationFilter; import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter; import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenIntrospectionEndpointFilter; @@ -89,13 +90,15 @@ public final class OAuth2AuthorizationServerConfigurer this.authorizationEndpointMatcher.matches(request) || this.tokenEndpointMatcher.matches(request) || this.tokenIntrospectionEndpointMatcher.matches(request) || this.tokenRevocationEndpointMatcher.matches(request) || this.jwkSetEndpointMatcher.matches(request) || - this.oidcProviderConfigurationEndpointMatcher.matches(request); + this.oidcProviderConfigurationEndpointMatcher.matches(request) || + this.oauth2ServerConfigurationEndpointMatcher.matches(request); /** * Sets the repository of registered clients. @@ -214,6 +217,10 @@ public void configure(B builder) { OidcProviderConfigurationEndpointFilter oidcProviderConfigurationEndpointFilter = new OidcProviderConfigurationEndpointFilter(providerSettings); builder.addFilterBefore(postProcess(oidcProviderConfigurationEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class); + + OAuth2AuthorizationServerConfigurationEndpointFilter authorizationServerConfigurationFilter + = new OAuth2AuthorizationServerConfigurationEndpointFilter(providerSettings); + builder.addFilterBefore(postProcess(authorizationServerConfigurationFilter), AbstractPreAuthenticatedProcessingFilter.class); } JWKSource jwkSource = getJwkSource(builder); @@ -277,6 +284,8 @@ private void initEndpointMatchers(ProviderSettings providerSettings) { providerSettings.jwkSetEndpoint(), HttpMethod.GET.name()); this.oidcProviderConfigurationEndpointMatcher = new AntPathRequestMatcher( OidcProviderConfigurationEndpointFilter.DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI, HttpMethod.GET.name()); + this.oauth2ServerConfigurationEndpointMatcher = new AntPathRequestMatcher( + OAuth2AuthorizationServerConfigurationEndpointFilter.DEFAULT_OAUTH2_AUTHORIZATION_SERVER_CONFIGURATION_ENDPOINT_URI, HttpMethod.GET.name()); } private static void validateProviderSettings(ProviderSettings providerSettings) { diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/AbstractOAuth2AuthorizationServerConfiguration.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/AbstractOAuth2AuthorizationServerConfiguration.java new file mode 100644 index 000000000..d51c99138 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/AbstractOAuth2AuthorizationServerConfiguration.java @@ -0,0 +1,370 @@ +/* + * Copyright 2020 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.core; + +import org.springframework.util.Assert; + +import java.io.Serializable; +import java.net.URI; +import java.net.URL; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + + +/** + * A base representation of a Provider Configuration response, returned by an endpoint defined + * either in OpenID Connect Discovery 1.0 or OAuth 2.0 Authorization Server Metadata. + * It contains a set of claims about the Provider's configuration. + * + * @author Daniel Garnier-Moiroux + * @see OAuth2AuthorizationServerMetadataClaimAccessor + * @since 0.1.1 + * @see 4.2. OpenID Provider Configuration Response + * @see 3.2. Authorization Server Metadata Response + */ +public abstract class AbstractOAuth2AuthorizationServerConfiguration implements OAuth2AuthorizationServerMetadataClaimAccessor, Serializable { + private static final long serialVersionUID = Version.SERIAL_VERSION_UID; + + protected final Map claims; + + protected AbstractOAuth2AuthorizationServerConfiguration(Map claims) { + Assert.notEmpty(claims, "claims cannot be empty"); + this.claims = Collections.unmodifiableMap(new LinkedHashMap<>(claims)); + } + + /** + * Returns the Authorization Server metadata. + * + * @return a {@code Map} of the metadata values + */ + @Override + public Map getClaims() { + return this.claims; + } + + /** + * An abstract builder for subclasses of {@link AbstractOAuth2AuthorizationServerConfiguration}. + */ + protected static abstract class AbstractBuilder> { + + protected final Map claims = new LinkedHashMap<>(); + + protected AbstractBuilder() { + } + + @SuppressWarnings("unchecked") + protected B getThis() { return (B) this; }; // avoid unchecked casts in subclasses by using "getThis()" instead of "(B) this" + + /** + * Use this {@code issuer} in the resulting {@link AbstractOAuth2AuthorizationServerConfiguration}, REQUIRED. + * + * @param issuer the {@code URL} of the Authorization Server's Issuer Identifier + * @return the {@link AbstractBuilder} for further configuration + */ + public B issuer(String issuer) { + return claim(OAuth2AuthorizationServerMetadataClaimNames.ISSUER, issuer); + } + + /** + * Use this {@code authorization_endpoint} in the resulting {@link AbstractOAuth2AuthorizationServerConfiguration}, REQUIRED. + * + * @param authorizationEndpoint the {@code URL} of the Authorization Server's OAuth 2.0 Authorization Endpoint + * @return the {@link AbstractBuilder} for further configuration + */ + public B authorizationEndpoint(String authorizationEndpoint) { + return claim(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT, authorizationEndpoint); + } + + /** + * Use this {@code token_endpoint} in the resulting {@link AbstractOAuth2AuthorizationServerConfiguration}, REQUIRED. + * + * @param tokenEndpoint the {@code URL} of the Authorization Server's OAuth 2.0 Token Endpoint + * @return the {@link AbstractBuilder} for further configuration + */ + public B tokenEndpoint(String tokenEndpoint) { + return claim(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT, tokenEndpoint); + } + + /** + * Add this Authentication Method to the collection of {@code token_endpoint_auth_methods_supported} + * in the resulting {@link AbstractOAuth2AuthorizationServerConfiguration}, OPTIONAL. + * + * @param authenticationMethod the OAuth 2.0 Authentication Method supported by the Token Endpoint + * @return the {@link AbstractBuilder} for further configuration + */ + public B tokenEndpointAuthenticationMethod(String authenticationMethod) { + addClaimToClaimList(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, authenticationMethod); + return getThis(); + } + + /** + * A {@code Consumer} of the Token Endpoint Authentication Method(s) allowing the ability to add, replace, or remove. + * + * @param authenticationMethodsConsumer a {@code Consumer} of the Token Endpoint Authentication Method(s) + * @return the {@link AbstractBuilder} for further configuration + */ + public B tokenEndpointAuthenticationMethods(Consumer> authenticationMethodsConsumer) { + acceptClaimValues(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, authenticationMethodsConsumer); + return getThis(); + } + + /** + * Use this {@code jwks_uri} in the resulting {@link AbstractOAuth2AuthorizationServerConfiguration}, REQUIRED. + * + * @param jwkSetUri the {@code URL} of the Authorization Server's JSON Web Key Set document + * @return the {@link AbstractBuilder} for further configuration + */ + public B jwkSetUri(String jwkSetUri) { + return claim(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI, jwkSetUri); + } + + /** + * Add this Response Type to the collection of {@code response_types_supported} in the resulting + * {@link AbstractOAuth2AuthorizationServerConfiguration}. + * + * @param responseType the OAuth 2.0 {@code response_type} value that the Authorization Server supports + * @return the {@link AbstractBuilder} for further configuration + */ + public B responseType(String responseType) { + addClaimToClaimList(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, responseType); + return getThis(); + } + + /** + * A {@code Consumer} of the Response Type(s) allowing the ability to add, replace, or remove. + * + * @param responseTypesConsumer a {@code Consumer} of the Response Type(s) + * @return the {@link AbstractBuilder} for further configuration + */ + public B responseTypes(Consumer> responseTypesConsumer) { + acceptClaimValues(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, responseTypesConsumer); + return getThis(); + } + + /** + * Add this Grant Type to the collection of {@code grant_types_supported} in the resulting + * {@link AbstractOAuth2AuthorizationServerConfiguration}, OPTIONAL. + * + * @param grantType the OAuth 2.0 {@code grant_type} value that the Authorization Server supports + * @return the {@link AbstractBuilder} for further configuration + */ + public B grantType(String grantType) { + addClaimToClaimList(OAuth2AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED, grantType); + return getThis(); + } + + /** + * A {@code Consumer} of the Grant Type(s) allowing the ability to add, replace, or remove. + * + * @param grantTypesConsumer a {@code Consumer} of the Grant Type(s) + * @return the {@link AbstractBuilder} for further configuration + */ + public B grantTypes(Consumer> grantTypesConsumer) { + acceptClaimValues(OAuth2AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED, grantTypesConsumer); + return getThis(); + } + + /** + * Add this Scope to the collection of {@code scopes_supported} in the resulting + * {@link AbstractOAuth2AuthorizationServerConfiguration}, RECOMMENDED. + * + * @param scope the OAuth 2.0 {@code scope} value that the Authorization Server supports + * @return the {@link AbstractBuilder} for further configuration + */ + public B scope(String scope) { + addClaimToClaimList(OAuth2AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED, scope); + return getThis(); + } + + /** + * A {@code Consumer} of the Scopes(s) allowing the ability to add, replace, or remove. + * + * @param scopesConsumer a {@code Consumer} of the Scopes(s) + * @return the {@link AbstractBuilder} for further configuration + */ + public B scopes(Consumer> scopesConsumer) { + acceptClaimValues(OAuth2AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED, scopesConsumer); + return getThis(); + } + + /** + * Use this claim in the resulting {@link AbstractOAuth2AuthorizationServerConfiguration} + * + * @param name the claim name + * @param value the claim value + * @return the {@link AbstractBuilder} for further configuration + */ + public B claim(String name, Object value) { + Assert.hasText(name, "name cannot be empty"); + Assert.notNull(value, "value cannot be null"); + this.claims.put(name, value); + return getThis(); + } + + /** + * Provides access to every {@link #claim(String, Object)} declared so far with + * the possibility to add, replace, or remove. + * + * @param claimsConsumer a {@code Consumer} of the claims + * @return the {@link AbstractBuilder} for further configurations + */ + public B claims(Consumer> claimsConsumer) { + claimsConsumer.accept(this.claims); + return getThis(); + } + + /** + * Use this {@code revocation_endpoint} in the resulting {@link AbstractOAuth2AuthorizationServerConfiguration}, OPTIONAL. + * + * @param tokenRevocationEndpoint the {@code URL} of the OAuth 2.0 Authorization Server's Token Revocation Endpoint + * @return the {@link AbstractBuilder} for further configuration + */ + public B tokenRevocationEndpoint(String tokenRevocationEndpoint) { + return claim(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT, tokenRevocationEndpoint); + } + + /** + * Add this Authentication Method to the collection of {@code revocation_endpoint_auth_methods_supported} + * in the resulting {@link AbstractOAuth2AuthorizationServerConfiguration}, OPTIONAL. + * + * @param authenticationMethod the OAuth 2.0 Authentication Method supported by the Revocation Endpoint + * @return the {@link AbstractBuilder} for further configuration + */ + public B tokenRevocationEndpointAuthenticationMethod(String authenticationMethod) { + addClaimToClaimList(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT_AUTH_METHODS_SUPPORTED, authenticationMethod); + return getThis(); + } + + /** + * A {@code Consumer} of the Token Revocation Endpoint Authentication Method(s) allowing the ability to add, + * replace, or remove. + * + * @param authenticationMethodsConsumer a {@code Consumer} of the OAuth 2.0 Token Revocation Endpoint Authentication Method(s) + * @return the {@link AbstractBuilder} for further configuration + */ + public B tokenRevocationEndpointAuthenticationMethods(Consumer> authenticationMethodsConsumer) { + acceptClaimValues(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT_AUTH_METHODS_SUPPORTED, authenticationMethodsConsumer); + return getThis(); + } + + /** + * Add this Proof Key for Code Exchange (PKCE) Code Challenge Method to the collection of + * {@code code_challenge_methods_supported} in the resulting {@link AbstractOAuth2AuthorizationServerConfiguration}, OPTIONAL. + * + * @param codeChallengeMethod the Proof Key for Code Exchange (PKCE) Code Challenge Method + * supported by the Authorization Server + * @return the {@link AbstractBuilder} for further configuration + */ + public B codeChallengeMethod(String codeChallengeMethod) { + addClaimToClaimList(OAuth2AuthorizationServerMetadataClaimNames.CODE_CHALLENGE_METHODS_SUPPORTED, codeChallengeMethod); + return getThis(); + } + + /** + * A {@code Consumer} of the Proof Key for Code Exchange (PKCE) Code Challenge Method(s) allowing + * the ability to add, replace, or remove. + * + * @param codeChallengeMethodsConsumer a {@code Consumer} of the Proof Key for Code Exchange (PKCE) + * Code Challenge Method(s) + * @return the {@link AbstractBuilder} for further configuration + */ + public B codeChallengeMethods(Consumer> codeChallengeMethodsConsumer) { + acceptClaimValues(OAuth2AuthorizationServerMetadataClaimNames.CODE_CHALLENGE_METHODS_SUPPORTED, codeChallengeMethodsConsumer); + return getThis(); + } + + /** + * Creates the {@link AbstractOAuth2AuthorizationServerConfiguration}. + * + * @return the {@link AbstractOAuth2AuthorizationServerConfiguration} + */ + public abstract T build(); + + protected void validateCommonClaims() { + Assert.notNull(this.claims.get(OAuth2AuthorizationServerMetadataClaimNames.ISSUER), "issuer cannot be null"); + validateURL(this.claims.get(OAuth2AuthorizationServerMetadataClaimNames.ISSUER), "issuer must be a valid URL"); + Assert.notNull(this.claims.get(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT), "authorizationEndpoint cannot be null"); + validateURL(this.claims.get(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT), "authorizationEndpoint must be a valid URL"); + Assert.notNull(this.claims.get(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT), "tokenEndpoint cannot be null"); + validateURL(this.claims.get(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT), "tokenEndpoint must be a valid URL"); + Assert.notNull(this.claims.get(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI), "jwksUri cannot be null"); + validateURL(this.claims.get(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI), "jwksUri must be a valid URL"); + Assert.notNull(this.claims.get(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED), "responseTypes cannot be null"); + Assert.isInstanceOf(List.class, this.claims.get(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED), "responseTypes must be of type List"); + Assert.notEmpty((List) this.claims.get(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED), "responseTypes cannot be empty"); + if (this.claims.get(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT) != null) { + validateURL(this.claims.get(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT), "tokenRevocationEndpoint must be a valid URL"); + } + } + + /** + * Remove claims of type Collection that have a size of zero. + *

+ * Both 3.2. Authorization Server Metadata Response + * and 4.2. OpenID Provider Configuration Response + * state "Claims with zero elements MUST be omitted from the response." + */ + protected void removeEmptyClaims() { + Set claimsToRemove = this.claims.entrySet() + .stream() + .filter(entry -> entry.getValue() != null) + .filter(entry -> Collection.class.isAssignableFrom(entry.getValue().getClass())) + .filter(entry -> ((Collection) entry.getValue()).size() == 0) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + + for (String claimToRemove : claimsToRemove) { + this.claims.remove(claimToRemove); + } + } + + protected static void validateURL(Object url, String errorMessage) { + if (URL.class.isAssignableFrom(url.getClass())) { + return; + } + + try { + new URI(url.toString()).toURL(); + } catch (Exception ex) { + throw new IllegalArgumentException(errorMessage, ex); + } + } + + @SuppressWarnings("unchecked") + protected void addClaimToClaimList(String name, String value) { + Assert.hasText(name, "name cannot be empty"); + Assert.notNull(value, "value cannot be null"); + this.claims.computeIfAbsent(name, k -> new LinkedList()); + ((List) this.claims.get(name)).add(value); + } + + @SuppressWarnings("unchecked") + protected void acceptClaimValues(String name, Consumer> valuesConsumer) { + Assert.hasText(name, "name cannot be empty"); + Assert.notNull(valuesConsumer, "valuesConsumer cannot be null"); + this.claims.computeIfAbsent(name, k -> new LinkedList()); + List values = (List) this.claims.get(name); + valuesConsumer.accept(values); + } + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthorizationServerMetadataClaimAccessor.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthorizationServerMetadataClaimAccessor.java new file mode 100644 index 000000000..f83df1c87 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthorizationServerMetadataClaimAccessor.java @@ -0,0 +1,134 @@ +/* + * Copyright 2020 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.core; + + +import java.net.URL; +import java.util.List; + +/** + * A base {@link ClaimAccessor} for the "claims" the Authorization Server can make about + * its configuration, used either in OpenID Connect Discovery 1.0 or OAuth 2.0 Authorization + * Server Metadata. + * + * @author Daniel Garnier-Moiroux + * @since 0.1.1 + * @see ClaimAccessor + * @see 2. Authorization Server Metadata + * @see 3. OpenID Provider Metadata + */ +public interface OAuth2AuthorizationServerMetadataClaimAccessor extends ClaimAccessor { + + /** + * Returns the {@code URL} the Authorization Server asserts as its Issuer Identifier {@code (issuer)}. + * + * @return the {@code URL} the Authorization Server asserts as its Issuer Identifier + */ + default URL getIssuer() { + return getClaimAsURL(OAuth2AuthorizationServerMetadataClaimNames.ISSUER); + } + + /** + * Returns the {@code URL} of the OAuth 2.0 Authorization Endpoint {@code (authorization_endpoint)}. + * + * @return the {@code URL} of the OAuth 2.0 Authorization Endpoint + */ + default URL getAuthorizationEndpoint() { + return getClaimAsURL(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT); + } + + /** + * Returns the {@code URL} of the OAuth 2.0 Token Endpoint {@code (token_endpoint)}. + * + * @return the {@code URL} of the OAuth 2.0 Token Endpoint + */ + default URL getTokenEndpoint() { + return getClaimAsURL(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT); + } + + /** + * Returns the client authentication methods supported by the OAuth 2.0 Token Endpoint {@code (token_endpoint_auth_methods_supported)}. + * + * @return the client authentication methods supported by the OAuth 2.0 Token Endpoint + */ + default List getTokenEndpointAuthenticationMethods() { + return getClaimAsStringList(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED); + } + + /** + * Returns the {@code URL} of the JSON Web Key Set {@code (jwks_uri)}. + * + * @return the {@code URL} of the JSON Web Key Set + */ + default URL getJwkSetUri() { + return getClaimAsURL(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI); + } + + /** + * Returns the OAuth 2.0 {@code response_type} values supported {@code (response_types_supported)}. + * + * @return the OAuth 2.0 {@code response_type} values supported + */ + default List getResponseTypes() { + return getClaimAsStringList(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED); + } + + /** + * Returns the OAuth 2.0 {@code grant_type} values supported {@code (grant_types_supported)}. + * + * @return the OAuth 2.0 {@code grant_type} values supported + */ + default List getGrantTypes() { + return getClaimAsStringList(OAuth2AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED); + } + + /** + * Returns the OAuth 2.0 {@code scope} values supported {@code (scopes_supported)}. + * + * @return the OAuth 2.0 {@code scope} values supported + */ + default List getScopes() { + return this.getClaimAsStringList(OAuth2AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED); + } + + /** + * Returns the {@code URL} of the OAuth 2.0 Token Revocation Endpoint {@code (revocation_endpoint)}. + * + * @return the {@code URL} of the OAuth 2.0 Token Revocation Endpoint + */ + default URL getTokenRevocationEndpoint() { + return this.getClaimAsURL(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT); + } + + /** + * Returns the client authentication methods supported by the OAuth 2.0 Token Revocation Endpoint {@code (revocation_endpoint_auth_methods_supported)}. + * + * @return the client authentication methods supported by the OAuth 2.0 Token Revocation Endpoint + */ + default List getTokenRevocationEndpointAuthenticationMethods() { + return this.getClaimAsStringList(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT_AUTH_METHODS_SUPPORTED); + } + + /** + * Returns the Proof Key for Code Exchange (PKCE) code challenge methods supported by the + * OAuth 2.0 Authorization Server {@code (code_challenge_methods_supported)}. + * + * @return the code challenge methods supported by the OAuth 2.0 Authorization Server + */ + default List getCodeChallengeMethods() { + return this.getClaimAsStringList(OAuth2AuthorizationServerMetadataClaimNames.CODE_CHALLENGE_METHODS_SUPPORTED); + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthorizationServerMetadataClaimNames.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthorizationServerMetadataClaimNames.java new file mode 100644 index 000000000..a9c126cba --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthorizationServerMetadataClaimNames.java @@ -0,0 +1,87 @@ +/* + * Copyright 2020 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.core; + +import org.springframework.security.oauth2.core.oidc.OidcProviderMetadataClaimNames; + +/** + * The names of the "claims" an Authorization Server can make about its configuration, + * used either in OpenID Connect Discovery 1.0 or OAuth 2.0 Authorization Server Metadata. + * + * @author Daniel Garnier-Moiroux + * @since 0.1.1 + * @see OidcProviderMetadataClaimNames + * @see 2. Authorization Server Metadata + * @see 3. OpenID Provider Metadata + */ +public interface OAuth2AuthorizationServerMetadataClaimNames { + + /** + * {@code issuer} - the {@code URL} the Authorization Server asserts as its Issuer Identifier + */ + String ISSUER = "issuer"; + + /** + * {@code authorization_endpoint} - the {@code URL} of the OAuth 2.0 Authorization Endpoint + */ + String AUTHORIZATION_ENDPOINT = "authorization_endpoint"; + + /** + * {@code token_endpoint} - the {@code URL} of the OAuth 2.0 Token Endpoint + */ + String TOKEN_ENDPOINT = "token_endpoint"; + + /** + * {@code token_endpoint_auth_methods_supported} - the client authentication methods supported by the OAuth 2.0 Token Endpoint + */ + String TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED = "token_endpoint_auth_methods_supported"; + + /** + * {@code jwks_uri} - the {@code URL} of the JSON Web Key Set + */ + String JWKS_URI = "jwks_uri"; + + /** + * {@code response_types_supported} - the OAuth 2.0 {@code response_type} values supported + */ + String RESPONSE_TYPES_SUPPORTED = "response_types_supported"; + + /** + * {@code grant_types_supported} - the OAuth 2.0 {@code grant_type} values supported + */ + String GRANT_TYPES_SUPPORTED = "grant_types_supported"; + + /** + * {@code scopes_supported} - the OAuth 2.0 {@code scope} values supported + */ + String SCOPES_SUPPORTED = "scopes_supported"; + + /** + * {@code revocation_endpoint} - the {@code URL} of the OAuth 2.0 Token Revocation Endpoint + */ + String REVOCATION_ENDPOINT = "revocation_endpoint"; + + /** + * {@code token_endpoint_auth_methods_supported} - the client authentication methods supported by the OAuth 2.0 Token Revocation Endpoint + */ + String REVOCATION_ENDPOINT_AUTH_METHODS_SUPPORTED = "revocation_endpoint_auth_methods_supported"; + + /** + * {@code code_challenge_methods_supported} - the Proof Key for Code Exchange (PKCE) code challenge methods + * supported by the OAuth 2.0 Authorization Server + */ + String CODE_CHALLENGE_METHODS_SUPPORTED = "code_challenge_methods_supported"; +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationServerConfiguration.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationServerConfiguration.java new file mode 100644 index 000000000..be5e44741 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationServerConfiguration.java @@ -0,0 +1,92 @@ +/* + * Copyright 2020 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.core.endpoint; + +import org.springframework.security.oauth2.core.AbstractOAuth2AuthorizationServerConfiguration; +import org.springframework.security.oauth2.core.OAuth2AuthorizationServerMetadataClaimAccessor; +import org.springframework.security.oauth2.core.Version; +import org.springframework.util.Assert; + +import java.io.Serializable; +import java.util.Map; + +/** + * A representation of an OAuth 2.0 Authorization Server Configuration response, + * which is returned form an OAuth 2.0 Authorization Server's Configuration Endpoint, + * and contains a set of claims about the Authorization Server's configuration. + * The claims are defined by the OAuth 2.0 Authorization Server Metadata + * specification (RFC 8414). + * + * @author Daniel Garnier-Moiroux + * @since 0.1.1 + * @see AbstractOAuth2AuthorizationServerConfiguration + * @see OAuth2AuthorizationServerMetadataClaimAccessor + * @see 3.2. Authorization Server Metadata Response + */ +public final class OAuth2AuthorizationServerConfiguration extends AbstractOAuth2AuthorizationServerConfiguration + implements OAuth2AuthorizationServerMetadataClaimAccessor, Serializable { + private static final long serialVersionUID = Version.SERIAL_VERSION_UID; + + private OAuth2AuthorizationServerConfiguration(Map claims) { + super(claims); + } + + /** + * Constructs a new {@link Builder} with empty claims. + * + * @return the {@link Builder} + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Constructs a new {@link Builder} with the provided claims. + * + * @param claims the claims to initialize the builder + * @return the {@link Builder} + */ + public static Builder withClaims(Map claims) { + Assert.notEmpty(claims, "claims cannot be empty"); + return new Builder() + .claims(c -> c.putAll(claims)); + } + + /** + * Helps configure an {@link OAuth2AuthorizationServerConfiguration}. + */ + public static class Builder + extends AbstractOAuth2AuthorizationServerConfiguration.AbstractBuilder { + private Builder() { + } + + /** + * Validate the claims and build the {@link OAuth2AuthorizationServerConfiguration}. + *

+ * The following claims are REQUIRED: + * {@code issuer}, {@code authorization_endpoint}, {@code token_endpoint}, + * {@code jwks_uri} and {@code response_types_supported}. + * + * @return the {@link OAuth2AuthorizationServerConfiguration} + */ + public OAuth2AuthorizationServerConfiguration build() { + validateCommonClaims(); + removeEmptyClaims(); + return new OAuth2AuthorizationServerConfiguration(this.claims); + } + + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/endpoint/PkceCodeChallengeMethod2.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/endpoint/PkceCodeChallengeMethod2.java new file mode 100644 index 000000000..558a9c3a6 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/endpoint/PkceCodeChallengeMethod2.java @@ -0,0 +1,82 @@ +/* + * Copyright 2020 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.core.endpoint; + +import org.springframework.security.oauth2.core.Version; +import org.springframework.util.Assert; + +import java.io.Serializable; + + +/** + * TODO + * This class is temporary and will be removed after upgrading to Spring Security 5.5.0 GA. + * + * The {@code code_challenge_method} is consumed by the authorization endpoint. The client sets + * the {@code code_challenge_method} parameter and a {@code code_challenge} derived from the + * {@code code_verifier} using the {@code code_challenge_method}. + * + *

+ * The {@code code_challenge_method} parameter value may be one of "S256" or + * "plain". If the client is capable of using "S256", it MUST use + * "S256", as it is mandatory to implement on the server. + * + * @author Daniel Garnier-Moiroux + * @since 0.1.1 + * @see 6.2. PKCE Code Challenge Method Registry + */ +public final class PkceCodeChallengeMethod2 implements Serializable { + + private static final long serialVersionUID = Version.SERIAL_VERSION_UID; + + public static final PkceCodeChallengeMethod2 S256 = new PkceCodeChallengeMethod2("S256"); + + public static final PkceCodeChallengeMethod2 PLAIN = new PkceCodeChallengeMethod2("plain"); + + private final String value; + + private PkceCodeChallengeMethod2(String value) { + Assert.hasText(value, "value cannot be empty"); + this.value = value; + } + + /** + * Returns the value of the authorization response type. + * + * @return the value of the authorization response type + */ + public String getValue() { + return this.value; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || this.getClass() != obj.getClass()) { + return false; + } + PkceCodeChallengeMethod2 that = (PkceCodeChallengeMethod2) obj; + return this.getValue().equals(that.getValue()); + } + + @Override + public int hashCode() { + return this.getValue().hashCode(); + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2AuthorizationServerConfigurationHttpMessageConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2AuthorizationServerConfigurationHttpMessageConverter.java new file mode 100644 index 000000000..608da96bd --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2AuthorizationServerConfigurationHttpMessageConverter.java @@ -0,0 +1,162 @@ +/* + * Copyright 2020 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.core.http.converter; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.AbstractHttpMessageConverter; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.security.oauth2.core.OAuth2AuthorizationServerMetadataClaimNames; +import org.springframework.security.oauth2.core.converter.ClaimConversionService; +import org.springframework.security.oauth2.core.converter.ClaimTypeConverter; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationServerConfiguration; +import org.springframework.util.Assert; + +import java.net.URL; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + + +/** + * A {@link HttpMessageConverter} for an {@link OAuth2AuthorizationServerConfiguration OAuth 2.0 Authorization Server Configuration Response}. + * + * @author Daniel Garnier-Moiroux + * @since 0.1.1 + * @see AbstractHttpMessageConverter + * @see OAuth2AuthorizationServerConfiguration + */ +public class OAuth2AuthorizationServerConfigurationHttpMessageConverter + extends AbstractHttpMessageConverter { + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = + new ParameterizedTypeReference>() {}; + + private final GenericHttpMessageConverter jsonMessageConverter = HttpMessageConverters.getJsonMessageConverter(); + + private Converter, OAuth2AuthorizationServerConfiguration> authorizationServerConfigurationConverter = new OAuth2AuthorizationServerConfigurationConverter(); + private Converter> authorizationServerConfigurationParametersConverter = OAuth2AuthorizationServerConfiguration::getClaims; + + public OAuth2AuthorizationServerConfigurationHttpMessageConverter() { + super(MediaType.APPLICATION_JSON, new MediaType("application", "*+json")); + } + + @Override + protected boolean supports(Class clazz) { + return OAuth2AuthorizationServerConfiguration.class.isAssignableFrom(clazz); + } + + @Override + @SuppressWarnings("unchecked") + protected OAuth2AuthorizationServerConfiguration readInternal(Class clazz, HttpInputMessage inputMessage) + throws HttpMessageNotReadableException { + try { + Map serverConfigurationParameters = + (Map) this.jsonMessageConverter.read(STRING_OBJECT_MAP.getType(), null, inputMessage); + return this.authorizationServerConfigurationConverter.convert(serverConfigurationParameters); + } catch (Exception ex) { + throw new HttpMessageNotReadableException( + "An error occurred reading the OAuth 2.0 Authorization Server Configuration: " + ex.getMessage(), ex, inputMessage); + } + } + + @Override + protected void writeInternal(OAuth2AuthorizationServerConfiguration providerConfiguration, HttpOutputMessage outputMessage) + throws HttpMessageNotWritableException { + try { + Map providerConfigurationResponseParameters = + this.authorizationServerConfigurationParametersConverter.convert(providerConfiguration); + this.jsonMessageConverter.write( + providerConfigurationResponseParameters, + STRING_OBJECT_MAP.getType(), + MediaType.APPLICATION_JSON, + outputMessage + ); + } catch (Exception ex) { + throw new HttpMessageNotWritableException( + "An error occurred writing the OAuth 2.0 Authorization Server Configuration: " + ex.getMessage(), ex); + } + } + + /** + * Sets the {@link Converter} used for converting the OAuth 2.0 Authorization Server Configuration + * parameters to an {@link OAuth2AuthorizationServerConfiguration}. + * + * @param authorizationServerConfigurationConverter the {@link Converter} used for converting to + * an {@link OAuth2AuthorizationServerConfiguration}. + */ + public void setAuthorizationServerConfigurationConverter(Converter, OAuth2AuthorizationServerConfiguration> authorizationServerConfigurationConverter) { + Assert.notNull(authorizationServerConfigurationConverter, "authorizationServerConfigurationConverter cannot be null"); + this.authorizationServerConfigurationConverter = authorizationServerConfigurationConverter; + } + + /** + * Sets the {@link Converter} used for converting the {@link OAuth2AuthorizationServerConfiguration} to a + * {@code Map} representation of the OAuth 2.0 Authorization Server Configuration. + * + * @param authorizationServerConfigurationParametersConverter the {@link Converter} used for converting to a + * {@code Map} representation of the OAuth 2.0 Authorization Server Configuration. + */ + public void setAuthorizationServerConfigurationParametersConverter(Converter> authorizationServerConfigurationParametersConverter) { + Assert.notNull(authorizationServerConfigurationParametersConverter, "authorizationServerConfigurationParametersConverter cannot be null"); + this.authorizationServerConfigurationParametersConverter = authorizationServerConfigurationParametersConverter; + } + + private static final class OAuth2AuthorizationServerConfigurationConverter implements Converter, OAuth2AuthorizationServerConfiguration> { + private static final ClaimConversionService CLAIM_CONVERSION_SERVICE = ClaimConversionService.getSharedInstance(); + private static final TypeDescriptor OBJECT_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(Object.class); + private static final TypeDescriptor STRING_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(String.class); + private static final TypeDescriptor URL_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(URL.class); + private final ClaimTypeConverter claimTypeConverter; + + private OAuth2AuthorizationServerConfigurationConverter() { + Map> claimNameToConverter = new HashMap<>(); + Converter collectionStringConverter = getConverter( + TypeDescriptor.collection(Collection.class, STRING_TYPE_DESCRIPTOR)); + Converter urlConverter = getConverter(URL_TYPE_DESCRIPTOR); + + claimNameToConverter.put(OAuth2AuthorizationServerMetadataClaimNames.ISSUER, urlConverter); + claimNameToConverter.put(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT, urlConverter); + claimNameToConverter.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT, urlConverter); + claimNameToConverter.put(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT, urlConverter); + claimNameToConverter.put(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI, urlConverter); + claimNameToConverter.put(OAuth2AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED, collectionStringConverter); + claimNameToConverter.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, collectionStringConverter); + claimNameToConverter.put(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT_AUTH_METHODS_SUPPORTED, collectionStringConverter); + claimNameToConverter.put(OAuth2AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED, collectionStringConverter); + claimNameToConverter.put(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, collectionStringConverter); + claimNameToConverter.put(OAuth2AuthorizationServerMetadataClaimNames.CODE_CHALLENGE_METHODS_SUPPORTED, collectionStringConverter); + this.claimTypeConverter = new ClaimTypeConverter(claimNameToConverter); + } + + @Override + public OAuth2AuthorizationServerConfiguration convert(Map source) { + Map parsedClaims = this.claimTypeConverter.convert(source); + return OAuth2AuthorizationServerConfiguration.withClaims(parsedClaims).build(); + } + + private static Converter getConverter(TypeDescriptor targetDescriptor) { + return (source) -> CLAIM_CONVERSION_SERVICE.convert(source, OBJECT_TYPE_DESCRIPTOR, targetDescriptor); + } + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderConfiguration.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderConfiguration.java index 2d7471e28..d1d10572c 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderConfiguration.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderConfiguration.java @@ -15,16 +15,12 @@ */ package org.springframework.security.oauth2.core.oidc; -import org.springframework.security.oauth2.jose.jws.JwsAlgorithm; +import org.springframework.security.oauth2.core.AbstractOAuth2AuthorizationServerConfiguration; import org.springframework.security.oauth2.core.Version; +import org.springframework.security.oauth2.jose.jws.JwsAlgorithm; import org.springframework.util.Assert; import java.io.Serializable; -import java.net.URI; -import java.net.URL; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -38,24 +34,15 @@ * @author Daniel Garnier-Moiroux * @since 0.1.0 * @see OidcProviderMetadataClaimAccessor + * @see AbstractOAuth2AuthorizationServerConfiguration * @see 4.2. OpenID Provider Configuration Response */ -public final class OidcProviderConfiguration implements OidcProviderMetadataClaimAccessor, Serializable { +public final class OidcProviderConfiguration extends AbstractOAuth2AuthorizationServerConfiguration + implements OidcProviderMetadataClaimAccessor, Serializable { private static final long serialVersionUID = Version.SERIAL_VERSION_UID; - private final Map claims; private OidcProviderConfiguration(Map claims) { - this.claims = Collections.unmodifiableMap(new LinkedHashMap<>(claims)); - } - - /** - * Returns the OpenID Provider Configuration metadata. - * - * @return a {@code Map} of the metadata values - */ - @Override - public Map getClaims() { - return this.claims; + super(claims); } /** @@ -81,121 +68,10 @@ public static Builder withClaims(Map claims) { /** * Helps configure an {@link OidcProviderConfiguration} */ - public static class Builder { - private final Map claims = new LinkedHashMap<>(); - + public static class Builder extends AbstractOAuth2AuthorizationServerConfiguration.AbstractBuilder { private Builder() { } - /** - * Use this {@code issuer} in the resulting {@link OidcProviderConfiguration}, REQUIRED. - * - * @param issuer the URL of the OpenID Provider's Issuer Identifier - * @return the {@link Builder} for further configuration - */ - public Builder issuer(String issuer) { - return claim(OidcProviderMetadataClaimNames.ISSUER, issuer); - } - - /** - * Use this {@code authorization_endpoint} in the resulting {@link OidcProviderConfiguration}, REQUIRED. - * - * @param authorizationEndpoint the URL of the OpenID Provider's OAuth 2.0 Authorization Endpoint - * @return the {@link Builder} for further configuration - */ - public Builder authorizationEndpoint(String authorizationEndpoint) { - return claim(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT, authorizationEndpoint); - } - - /** - * Use this {@code token_endpoint} in the resulting {@link OidcProviderConfiguration}, REQUIRED. - * - * @param tokenEndpoint the URL of the OpenID Provider's OAuth 2.0 Token Endpoint - * @return the {@link Builder} for further configuration - */ - public Builder tokenEndpoint(String tokenEndpoint) { - return claim(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT, tokenEndpoint); - } - - /** - * Add this Authentication Method to the collection of {@code token_endpoint_auth_methods_supported} - * in the resulting {@link OidcProviderConfiguration}, OPTIONAL. - * - * @param authenticationMethod the OAuth 2.0 Authentication Method supported by the Token endpoint - * @return the {@link Builder} for further configuration - */ - public Builder tokenEndpointAuthenticationMethod(String authenticationMethod) { - addClaimToClaimList(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, authenticationMethod); - return this; - } - - /** - * A {@code Consumer} of the Token Endpoint Authentication Method(s) allowing the ability to add, replace, or remove. - * - * @param authenticationMethodsConsumer a {@code Consumer} of the Token Endpoint Authentication Method(s) - * @return the {@link Builder} for further configuration - */ - public Builder tokenEndpointAuthenticationMethods(Consumer> authenticationMethodsConsumer) { - acceptClaimValues(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, authenticationMethodsConsumer); - return this; - } - - /** - * Use this {@code jwks_uri} in the resulting {@link OidcProviderConfiguration}, REQUIRED. - * - * @param jwkSetUri the URL of the OpenID Provider's JSON Web Key Set document - * @return the {@link Builder} for further configuration - */ - public Builder jwkSetUri(String jwkSetUri) { - return claim(OidcProviderMetadataClaimNames.JWKS_URI, jwkSetUri); - } - - /** - * Add this Response Type to the collection of {@code response_types_supported} in the resulting - * {@link OidcProviderConfiguration}, REQUIRED. - * - * @param responseType the OAuth 2.0 {@code response_type} value that the OpenID Provider supports - * @return the {@link Builder} for further configuration - */ - public Builder responseType(String responseType) { - addClaimToClaimList(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, responseType); - return this; - } - - /** - * A {@code Consumer} of the Response Type(s) allowing the ability to add, replace, or remove. - * - * @param responseTypesConsumer a {@code Consumer} of the Response Type(s) - * @return the {@link Builder} for further configuration - */ - public Builder responseTypes(Consumer> responseTypesConsumer) { - acceptClaimValues(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, responseTypesConsumer); - return this; - } - - /** - * Add this Grant Type to the collection of {@code grant_types_supported} in the resulting - * {@link OidcProviderConfiguration}, OPTIONAL. - * - * @param grantType the OAuth 2.0 {@code grant_type} value that the OpenID Provider supports - * @return the {@link Builder} for further configuration - */ - public Builder grantType(String grantType) { - addClaimToClaimList(OidcProviderMetadataClaimNames.GRANT_TYPES_SUPPORTED, grantType); - return this; - } - - /** - * A {@code Consumer} of the Grant Type(s) allowing the ability to add, replace, or remove. - * - * @param grantTypesConsumer a {@code Consumer} of the Grant Type(s) - * @return the {@link Builder} for further configuration - */ - public Builder grantTypes(Consumer> grantTypesConsumer) { - acceptClaimValues(OidcProviderMetadataClaimNames.GRANT_TYPES_SUPPORTED, grantTypesConsumer); - return this; - } - /** * Add this Subject Type to the collection of {@code subject_types_supported} in the resulting * {@link OidcProviderConfiguration}, REQUIRED. @@ -219,29 +95,6 @@ public Builder subjectTypes(Consumer> subjectTypesConsumer) { return this; } - /** - * Add this Scope to the collection of {@code scopes_supported} in the resulting - * {@link OidcProviderConfiguration}, RECOMMENDED. - * - * @param scope the OAuth 2.0 {@code scope} value that the OpenID Provider supports - * @return the {@link Builder} for further configuration - */ - public Builder scope(String scope) { - addClaimToClaimList(OidcProviderMetadataClaimNames.SCOPES_SUPPORTED, scope); - return this; - } - - /** - * A {@code Consumer} of the Scopes(s) allowing the ability to add, replace, or remove. - * - * @param scopesConsumer a {@code Consumer} of the Scopes(s) - * @return the {@link Builder} for further configuration - */ - public Builder scopes(Consumer> scopesConsumer) { - acceptClaimValues(OidcProviderMetadataClaimNames.SCOPES_SUPPORTED, scopesConsumer); - return this; - } - /** * Add this {@link JwsAlgorithm JWS} signing algorithm to the collection of {@code id_token_signing_alg_values_supported} * in the resulting {@link OidcProviderConfiguration}, REQUIRED. @@ -297,27 +150,20 @@ public Builder claims(Consumer> claimsConsumer) { *

* The following claims are REQUIRED: * {@code issuer}, {@code authorization_endpoint}, {@code token_endpoint}, {@code jwks_uri}, - * {@code response_types_supported} and {@code subject_types_supported}. + * {@code response_types_supported}, {@code subject_types_supported} and + * {@code id_token_signing_alg_values_supported}. * * @return the {@link OidcProviderConfiguration} */ + @Override public OidcProviderConfiguration build() { - validateClaims(); + validateCommonClaims(); + validateOidcSpecificClaims(); + removeEmptyClaims(); return new OidcProviderConfiguration(this.claims); } - private void validateClaims() { - Assert.notNull(this.claims.get(OidcProviderMetadataClaimNames.ISSUER), "issuer cannot be null"); - validateURL(this.claims.get(OidcProviderMetadataClaimNames.ISSUER), "issuer must be a valid URL"); - Assert.notNull(this.claims.get(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT), "authorizationEndpoint cannot be null"); - validateURL(this.claims.get(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT), "authorizationEndpoint must be a valid URL"); - Assert.notNull(this.claims.get(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT), "tokenEndpoint cannot be null"); - validateURL(this.claims.get(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT), "tokenEndpoint must be a valid URL"); - Assert.notNull(this.claims.get(OidcProviderMetadataClaimNames.JWKS_URI), "jwksUri cannot be null"); - validateURL(this.claims.get(OidcProviderMetadataClaimNames.JWKS_URI), "jwksUri must be a valid URL"); - Assert.notNull(this.claims.get(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED), "responseTypes cannot be null"); - Assert.isInstanceOf(List.class, this.claims.get(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED), "responseTypes must be of type List"); - Assert.notEmpty((List) this.claims.get(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED), "responseTypes cannot be empty"); + private void validateOidcSpecificClaims() { Assert.notNull(this.claims.get(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED), "subjectTypes cannot be null"); Assert.isInstanceOf(List.class, this.claims.get(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED), "subjectTypes must be of type List"); Assert.notEmpty((List) this.claims.get(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED), "subjectTypes cannot be empty"); @@ -325,34 +171,5 @@ private void validateClaims() { Assert.isInstanceOf(List.class, this.claims.get(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED), "idTokenSigningAlgorithms must be of type List"); Assert.notEmpty((List) this.claims.get(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED), "idTokenSigningAlgorithms cannot be empty"); } - - private static void validateURL(Object url, String errorMessage) { - if (URL.class.isAssignableFrom(url.getClass())) { - return; - } - - try { - new URI(url.toString()).toURL(); - } catch (Exception ex) { - throw new IllegalArgumentException(errorMessage, ex); - } - } - - @SuppressWarnings("unchecked") - private void addClaimToClaimList(String name, String value) { - Assert.hasText(name, "name cannot be empty"); - Assert.notNull(value, "value cannot be null"); - this.claims.computeIfAbsent(name, k -> new LinkedList()); - ((List) this.claims.get(name)).add(value); - } - - @SuppressWarnings("unchecked") - private void acceptClaimValues(String name, Consumer> valuesConsumer) { - Assert.hasText(name, "name cannot be empty"); - Assert.notNull(valuesConsumer, "valuesConsumer cannot be null"); - this.claims.computeIfAbsent(name, k -> new LinkedList()); - List values = (List) this.claims.get(name); - valuesConsumer.accept(values); - } } } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderMetadataClaimAccessor.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderMetadataClaimAccessor.java index ce2322688..4b9a3e0af 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderMetadataClaimAccessor.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderMetadataClaimAccessor.java @@ -16,11 +16,12 @@ package org.springframework.security.oauth2.core.oidc; +import org.springframework.security.oauth2.core.OAuth2AuthorizationServerMetadataClaimAccessor; import org.springframework.security.oauth2.core.ClaimAccessor; import org.springframework.security.oauth2.jose.jws.JwsAlgorithm; import org.springframework.security.oauth2.jwt.Jwt; -import java.net.URL; + import java.util.List; /** @@ -30,74 +31,12 @@ * @author Daniel Garnier-Moiroux * @since 0.1.0 * @see ClaimAccessor + * @see OAuth2AuthorizationServerMetadataClaimAccessor * @see OidcProviderMetadataClaimNames * @see OidcProviderConfiguration * @see 3. OpenID Provider Metadata */ -public interface OidcProviderMetadataClaimAccessor extends ClaimAccessor { - - /** - * Returns the {@code URL} the OpenID Provider asserts as its Issuer Identifier {@code (issuer)}. - * - * @return the {@code URL} the OpenID Provider asserts as its Issuer Identifier - */ - default URL getIssuer() { - return getClaimAsURL(OidcProviderMetadataClaimNames.ISSUER); - } - - /** - * Returns the {@code URL} of the OAuth 2.0 Authorization Endpoint {@code (authorization_endpoint)}. - * - * @return the {@code URL} of the OAuth 2.0 Authorization Endpoint - */ - default URL getAuthorizationEndpoint() { - return getClaimAsURL(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT); - } - - /** - * Returns the {@code URL} of the OAuth 2.0 Token Endpoint {@code (token_endpoint)}. - * - * @return the {@code URL} of the OAuth 2.0 Token Endpoint - */ - default URL getTokenEndpoint() { - return getClaimAsURL(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT); - } - - /** - * Returns the client authentication methods supported by the OAuth 2.0 Token Endpoint {@code (token_endpoint_auth_methods_supported)}. - * - * @return the client authentication methods supported by the OAuth 2.0 Token Endpoint - */ - default List getTokenEndpointAuthenticationMethods() { - return getClaimAsStringList(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED); - } - - /** - * Returns the {@code URL} of the JSON Web Key Set {@code (jwks_uri)}. - * - * @return the {@code URL} of the JSON Web Key Set - */ - default URL getJwkSetUri() { - return getClaimAsURL(OidcProviderMetadataClaimNames.JWKS_URI); - } - - /** - * Returns the OAuth 2.0 {@code response_type} values supported {@code (response_types_supported)}. - * - * @return the OAuth 2.0 {@code response_type} values supported - */ - default List getResponseTypes() { - return getClaimAsStringList(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED); - } - - /** - * Returns the OAuth 2.0 {@code grant_type} values supported {@code (grant_types_supported)}. - * - * @return the OAuth 2.0 {@code grant_type} values supported - */ - default List getGrantTypes() { - return getClaimAsStringList(OidcProviderMetadataClaimNames.GRANT_TYPES_SUPPORTED); - } +public interface OidcProviderMetadataClaimAccessor extends OAuth2AuthorizationServerMetadataClaimAccessor { /** * Returns the Subject Identifier types supported {@code (subject_types_supported)}. @@ -108,15 +47,6 @@ default List getSubjectTypes() { return getClaimAsStringList(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED); } - /** - * Returns the OAuth 2.0 {@code scope} values supported {@code (scopes_supported)}. - * - * @return the OAuth 2.0 {@code scope} values supported - */ - default List getScopes() { - return getClaimAsStringList(OidcProviderMetadataClaimNames.SCOPES_SUPPORTED); - } - /** * Returns the {@link JwsAlgorithm JWS} signing algorithms supported for the {@link OidcIdToken ID Token} * to encode the claims in a {@link Jwt} {@code (id_token_signing_alg_values_supported)}. diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderMetadataClaimNames.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderMetadataClaimNames.java index 046dc5c77..dbd543273 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderMetadataClaimNames.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcProviderMetadataClaimNames.java @@ -15,6 +15,7 @@ */ package org.springframework.security.oauth2.core.oidc; +import org.springframework.security.oauth2.core.OAuth2AuthorizationServerMetadataClaimNames; import org.springframework.security.oauth2.jose.jws.JwsAlgorithm; /** @@ -25,53 +26,13 @@ * @since 0.1.0 * @see 3. OpenID Provider Metadata */ -public interface OidcProviderMetadataClaimNames { - - /** - * {@code issuer} - the {@code URL} the OpenID Provider asserts as its Issuer Identifier - */ - String ISSUER = "issuer"; - - /** - * {@code authorization_endpoint} - the {@code URL} of the OAuth 2.0 Authorization Endpoint - */ - String AUTHORIZATION_ENDPOINT = "authorization_endpoint"; - - /** - * {@code token_endpoint} - the {@code URL} of the OAuth 2.0 Token Endpoint - */ - String TOKEN_ENDPOINT = "token_endpoint"; - - /** - * {@code token_endpoint_auth_methods_supported} - the client authentication methods supported by the OAuth 2.0 Token Endpoint - */ - String TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED = "token_endpoint_auth_methods_supported"; - - /** - * {@code jwks_uri} - the {@code URL} of the JSON Web Key Set - */ - String JWKS_URI = "jwks_uri"; - - /** - * {@code response_types_supported} - the OAuth 2.0 {@code response_type} values supported - */ - String RESPONSE_TYPES_SUPPORTED = "response_types_supported"; - - /** - * {@code grant_types_supported} - the OAuth 2.0 {@code grant_type} values supported - */ - String GRANT_TYPES_SUPPORTED = "grant_types_supported"; +public interface OidcProviderMetadataClaimNames extends OAuth2AuthorizationServerMetadataClaimNames { /** * {@code subject_types_supported} - the Subject Identifier types supported */ String SUBJECT_TYPES_SUPPORTED = "subject_types_supported"; - /** - * {@code scopes_supported} - the OAuth 2.0 {@code scope} values supported - */ - String SCOPES_SUPPORTED = "scopes_supported"; - /** * {@code id_token_signing_alg_values_supported} - the {@link JwsAlgorithm JWS} signing algorithms supported for the {@link OidcIdToken ID Token} */ diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientAuthenticationProvider.java index 058154dec..20530c4fb 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientAuthenticationProvider.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientAuthenticationProvider.java @@ -31,6 +31,7 @@ import org.springframework.security.oauth2.core.OAuth2TokenType; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.endpoint.PkceCodeChallengeMethod2; import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; @@ -156,9 +157,9 @@ private static boolean authorizationCodeGrant(Map parameters) { private static boolean codeVerifierValid(String codeVerifier, String codeChallenge, String codeChallengeMethod) { if (!StringUtils.hasText(codeVerifier)) { return false; - } else if (!StringUtils.hasText(codeChallengeMethod) || "plain".equals(codeChallengeMethod)) { + } else if (!StringUtils.hasText(codeChallengeMethod) || PkceCodeChallengeMethod2.PLAIN.getValue().equals(codeChallengeMethod)) { return codeVerifier.equals(codeChallenge); - } else if ("S256".equals(codeChallengeMethod)) { + } else if (PkceCodeChallengeMethod2.S256.getValue().equals(codeChallengeMethod)) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] digest = md.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettings.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettings.java index 12c7d4984..11cf0325f 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettings.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettings.java @@ -70,7 +70,8 @@ public ProviderSettings issuer(String issuer) { } /** - * Returns the Provider's OAuth 2.0 Authorization endpoint. The default is {@code /oauth2/authorize}. + * Returns the Provider's OAuth 2.0 Authorization endpoint. + * The default is {@code /oauth2/authorize}. * * @return the Authorization endpoint */ @@ -89,7 +90,8 @@ public ProviderSettings authorizationEndpoint(String authorizationEndpoint) { } /** - * Returns the Provider's OAuth 2.0 Token endpoint. The default is {@code /oauth2/token}. + * Returns the Provider's OAuth 2.0 Token endpoint. + * The default is {@code /oauth2/token}. * * @return the Token endpoint */ diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java index 92c27c21d..0160013b7 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java @@ -75,20 +75,21 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } - OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.builder() + OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration + .builder() .issuer(this.providerSettings.issuer()) .authorizationEndpoint(asUrl(this.providerSettings.issuer(), this.providerSettings.authorizationEndpoint())) .tokenEndpoint(asUrl(this.providerSettings.issuer(), this.providerSettings.tokenEndpoint())) - .tokenEndpointAuthenticationMethod("client_secret_basic") // TODO: Use ClientAuthenticationMethod.CLIENT_SECRET_BASIC in Spring Security 5.5.0 - .tokenEndpointAuthenticationMethod("client_secret_post") // TODO: Use ClientAuthenticationMethod.CLIENT_SECRET_POST in Spring Security 5.5.0 + .tokenEndpointAuthenticationMethod("client_secret_basic") // TODO: Use ClientAuthenticationMethod.CLIENT_SECRET_BASIC in Spring Security 5.5.0 + .tokenEndpointAuthenticationMethod("client_secret_post") // TODO: Use ClientAuthenticationMethod.CLIENT_SECRET_POST in Spring Security 5.5.0 .jwkSetUri(asUrl(this.providerSettings.issuer(), this.providerSettings.jwkSetEndpoint())) - .responseType(OAuth2AuthorizationResponseType.CODE.getValue()) .grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) .grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) .grantType(AuthorizationGrantType.REFRESH_TOKEN.getValue()) + .responseType(OAuth2AuthorizationResponseType.CODE.getValue()) + .scope(OidcScopes.OPENID) .subjectType("public") .idTokenSigningAlgorithm(SignatureAlgorithm.RS256.getName()) - .scope(OidcScopes.OPENID) .build(); ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java index 2be1d5de1..02ba5bad3 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java @@ -48,6 +48,7 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.endpoint.PkceCodeChallengeMethod2; import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; import org.springframework.security.oauth2.core.oidc.OidcScopes; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; @@ -370,7 +371,7 @@ private void validateAuthorizationRequest(OAuth2AuthorizationRequestContext auth String codeChallengeMethod = authorizationRequestContext.getParameters().getFirst(PkceParameterNames.CODE_CHALLENGE_METHOD); if (StringUtils.hasText(codeChallengeMethod)) { if (authorizationRequestContext.getParameters().get(PkceParameterNames.CODE_CHALLENGE_METHOD).size() != 1 || - (!"S256".equals(codeChallengeMethod) && !"plain".equals(codeChallengeMethod))) { + (!PkceCodeChallengeMethod2.S256.getValue().equals(codeChallengeMethod) && !PkceCodeChallengeMethod2.PLAIN.getValue().equals(codeChallengeMethod))) { authorizationRequestContext.setError( createError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD, PKCE_ERROR_URI)); return; diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerConfigurationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerConfigurationEndpointFilter.java new file mode 100644 index 000000000..50572ae0f --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerConfigurationEndpointFilter.java @@ -0,0 +1,104 @@ +/* + * Copyright 2020 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.authorization.web; + +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationServerConfiguration; +import org.springframework.security.oauth2.core.endpoint.PkceCodeChallengeMethod2; +import org.springframework.security.oauth2.core.http.converter.OAuth2AuthorizationServerConfigurationHttpMessageConverter; +import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.server.authorization.config.ProviderSettings; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * A {@code Filter} that processes OAuth 2.0 Authorization Server Configuration Requests. + * + * @author Daniel Garnier-Moiroux + * @since 0.1.1 + * @see ProviderSettings + * @see 3. Obtaining Authorization Server Metadata + */ +public class OAuth2AuthorizationServerConfigurationEndpointFilter extends OncePerRequestFilter { + /** + * The default endpoint {@code URI} for OAuth 2.0 Authorization Server Configuration requests. + */ + public static final String DEFAULT_OAUTH2_AUTHORIZATION_SERVER_CONFIGURATION_ENDPOINT_URI = "/.well-known/oauth-authorization-server"; + + private final RequestMatcher requestMatcher; + private final ProviderSettings providerSettings; + private final OAuth2AuthorizationServerConfigurationHttpMessageConverter authorizationServerConfigurationHttpMessageConverter + = new OAuth2AuthorizationServerConfigurationHttpMessageConverter(); + + public OAuth2AuthorizationServerConfigurationEndpointFilter(ProviderSettings providerSettings) { + Assert.notNull(providerSettings, "providerSettings cannot be null"); + this.providerSettings = providerSettings; + this.requestMatcher = new AntPathRequestMatcher( + DEFAULT_OAUTH2_AUTHORIZATION_SERVER_CONFIGURATION_ENDPOINT_URI, + HttpMethod.GET.name() + ); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (!this.requestMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + + OAuth2AuthorizationServerConfiguration authorizationServerConfiguration = OAuth2AuthorizationServerConfiguration + .builder() + .issuer(this.providerSettings.issuer()) + .authorizationEndpoint(asUrl(this.providerSettings.issuer(), this.providerSettings.authorizationEndpoint())) + .tokenEndpoint(asUrl(this.providerSettings.issuer(), this.providerSettings.tokenEndpoint())) + .tokenEndpointAuthenticationMethod("client_secret_basic") // TODO: Use ClientAuthenticationMethod.CLIENT_SECRET_BASIC in Spring Security 5.5.0 + .tokenEndpointAuthenticationMethod("client_secret_post") // TODO: Use ClientAuthenticationMethod.CLIENT_SECRET_POST in Spring Security 5.5.0 + .tokenRevocationEndpoint(asUrl(this.providerSettings.issuer(), this.providerSettings.tokenRevocationEndpoint())) + .tokenRevocationEndpointAuthenticationMethod("client_secret_basic") // TODO: Use ClientAuthenticationMethod.CLIENT_SECRET_BASIC in Spring Security 5.5.0 + .tokenRevocationEndpointAuthenticationMethod("client_secret_post") // TODO: Use ClientAuthenticationMethod.CLIENT_SECRET_POST in Spring Security 5.5.0 + .jwkSetUri(asUrl(this.providerSettings.issuer(), this.providerSettings.jwkSetEndpoint())) + .grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) + .grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .grantType(AuthorizationGrantType.REFRESH_TOKEN.getValue()) + .responseType(OAuth2AuthorizationResponseType.CODE.getValue()) + .scope(OidcScopes.OPENID) + .codeChallengeMethod(PkceCodeChallengeMethod2.PLAIN.getValue()) + .codeChallengeMethod(PkceCodeChallengeMethod2.S256.getValue()) + .build(); + + ServletServerHttpResponse resp = new ServletServerHttpResponse(response); + this.authorizationServerConfigurationHttpMessageConverter.write( + authorizationServerConfiguration, MediaType.APPLICATION_JSON, resp); + } + + private static String asUrl(String issuer, String endpoint) { + return UriComponentsBuilder.fromUriString(issuer).path(endpoint).toUriString(); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurationTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurationTests.java new file mode 100644 index 000000000..51e2f5fb1 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurationTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2020 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.authorization; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; +import org.springframework.security.config.test.SpringTestRule; +import org.springframework.security.oauth2.jose.TestJwks; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.config.ProviderSettings; +import org.springframework.security.oauth2.server.authorization.web.OAuth2AuthorizationServerConfigurationEndpointFilter; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests for OAuth 2.0 Authorization Server Configuration. + * + * @author Daniel Garnier-Moiroux + */ +public class OAuth2AuthorizationServerConfigurationTests { + private static final String issuerUrl = "https://example.com/issuer1"; + private static JWKSource jwkSource; + + @Rule + public final SpringTestRule spring = new SpringTestRule(); + + @Autowired + private MockMvc mvc; + + @BeforeClass + public static void setupClass() { + JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK); + jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); + } + + @Test + public void requestWhenServerConfigurationRequestAndIssuerSetThenReturnServerConfigurationResponse() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + + this.mvc.perform(get(OAuth2AuthorizationServerConfigurationEndpointFilter.DEFAULT_OAUTH2_AUTHORIZATION_SERVER_CONFIGURATION_ENDPOINT_URI)) + .andExpect(status().is2xxSuccessful()) + .andExpect(jsonPath("issuer").value(issuerUrl)) + .andReturn(); + } + + @EnableWebSecurity + @Import(OAuth2AuthorizationServerConfiguration.class) + static class AuthorizationServerConfiguration { + + @Bean + RegisteredClientRepository registeredClientRepository() { + return mock(RegisteredClientRepository.class); + } + + @Bean + JWKSource jwkSource() { + return jwkSource; + } + + @Bean + ProviderSettings providerSettings() { + return new ProviderSettings().issuer(issuerUrl); + } + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationServerConfigurationTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationServerConfigurationTests.java new file mode 100644 index 000000000..61af91b11 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationServerConfigurationTests.java @@ -0,0 +1,451 @@ +/* + * Copyright 2020 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.core.endpoint; + +import org.junit.Test; +import org.springframework.security.oauth2.core.OAuth2AuthorizationServerMetadataClaimNames; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationServerConfiguration.Builder; + +import java.net.URL; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link OAuth2AuthorizationServerConfiguration}. + * + * @author Daniel Garnier-Moiroux + */ +public class OAuth2AuthorizationServerConfigurationTests { + private final Builder minimalConfigurationBuilder = + OAuth2AuthorizationServerConfiguration.builder() + .issuer("https://example.com/issuer1") + .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") + .tokenEndpoint("https://example.com/issuer1/oauth2/token") + .jwkSetUri("https://example.com/issuer1/oauth2/jwks") + .scope("openid") + .responseType("code"); + + @Test + public void buildWhenAllRequiredClaimsAndAdditionalClaimsThenCreated() { + OAuth2AuthorizationServerConfiguration authorizationServerConfiguration = OAuth2AuthorizationServerConfiguration.builder() + .issuer("https://example.com/issuer1") + .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") + .tokenEndpoint("https://example.com/issuer1/oauth2/token") + .tokenRevocationEndpoint("https://example.com/issuer1/oauth2/revoke") + .jwkSetUri("https://example.com/issuer1/oauth2/jwks") + .scope("openid") + .responseType("code") + .grantType("authorization_code") + .grantType("client_credentials") + .tokenEndpointAuthenticationMethod("client_secret_basic") + .tokenRevocationEndpointAuthenticationMethod("client_secret_basic") + .codeChallengeMethod("plain") + .codeChallengeMethod("S256") + .claim("a-claim", "a-value") + .build(); + + assertThat(authorizationServerConfiguration.getIssuer()).isEqualTo(url("https://example.com/issuer1")); + assertThat(authorizationServerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize")); + assertThat(authorizationServerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token")); + assertThat(authorizationServerConfiguration.getTokenRevocationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/revoke")); + assertThat(authorizationServerConfiguration.getJwkSetUri()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks")); + assertThat(authorizationServerConfiguration.getScopes()).containsExactly("openid"); + assertThat(authorizationServerConfiguration.getResponseTypes()).containsExactly("code"); + assertThat(authorizationServerConfiguration.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "client_credentials"); + assertThat(authorizationServerConfiguration.getTokenEndpointAuthenticationMethods()).containsExactly("client_secret_basic"); + assertThat(authorizationServerConfiguration.getTokenRevocationEndpointAuthenticationMethods()).containsExactly("client_secret_basic"); + assertThat(authorizationServerConfiguration.getCodeChallengeMethods()).containsExactlyInAnyOrder("plain", "S256"); + assertThat(authorizationServerConfiguration.getClaimAsString("a-claim")).isEqualTo("a-value"); + } + + @Test + public void buildWhenOnlyRequiredClaimsThenCreated() { + OAuth2AuthorizationServerConfiguration authorizationServerConfiguration = OAuth2AuthorizationServerConfiguration.builder() + .issuer("https://example.com/issuer1") + .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") + .tokenEndpoint("https://example.com/issuer1/oauth2/token") + .jwkSetUri("https://example.com/issuer1/oauth2/jwks") + .scope("openid") + .responseType("code") + .build(); + + assertThat(authorizationServerConfiguration.getIssuer()).isEqualTo(url("https://example.com/issuer1")); + assertThat(authorizationServerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize")); + assertThat(authorizationServerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token")); + assertThat(authorizationServerConfiguration.getJwkSetUri()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks")); + assertThat(authorizationServerConfiguration.getScopes()).containsExactly("openid"); + assertThat(authorizationServerConfiguration.getResponseTypes()).containsExactly("code"); + assertThat(authorizationServerConfiguration.getGrantTypes()).isNull(); + assertThat(authorizationServerConfiguration.getTokenEndpointAuthenticationMethods()).isNull(); + assertThat(authorizationServerConfiguration.getTokenRevocationEndpoint()).isNull(); + assertThat(authorizationServerConfiguration.getTokenRevocationEndpointAuthenticationMethods()).isNull(); + assertThat(authorizationServerConfiguration.getCodeChallengeMethods()).isNull(); + } + + @Test + public void buildFromClaimsThenCreated() { + HashMap claims = new HashMap<>(); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.ISSUER, "https://example.com/issuer1"); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT, "https://example.com/issuer1/oauth2/authorize"); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT, "https://example.com/issuer1/oauth2/token"); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI, "https://example.com/issuer1/oauth2/jwks"); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED, Collections.singletonList("openid")); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, Collections.singletonList("code")); + claims.put("some-claim", "some-value"); + + OAuth2AuthorizationServerConfiguration authorizationServerConfiguration = OAuth2AuthorizationServerConfiguration.withClaims(claims).build(); + + assertThat(authorizationServerConfiguration.getIssuer()).isEqualTo(url("https://example.com/issuer1")); + assertThat(authorizationServerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize")); + assertThat(authorizationServerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token")); + assertThat(authorizationServerConfiguration.getJwkSetUri()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks")); + assertThat(authorizationServerConfiguration.getScopes()).containsExactly("openid"); + assertThat(authorizationServerConfiguration.getResponseTypes()).containsExactly("code"); + assertThat(authorizationServerConfiguration.getGrantTypes()).isNull(); + assertThat(authorizationServerConfiguration.getTokenEndpointAuthenticationMethods()).isNull(); + assertThat(authorizationServerConfiguration.getTokenRevocationEndpoint()).isNull(); + assertThat(authorizationServerConfiguration.getTokenRevocationEndpointAuthenticationMethods()).isNull(); + assertThat(authorizationServerConfiguration.getCodeChallengeMethods()).isNull(); + assertThat(authorizationServerConfiguration.getClaimAsString("some-claim")).isEqualTo("some-value"); + } + + @Test + public void buildFromClaimsWhenUsingUrlsThenCreated() { + HashMap claims = new HashMap<>(); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.ISSUER, url("https://example.com/issuer1")); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT, url("https://example.com/issuer1/oauth2/authorize")); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT, url("https://example.com/issuer1/oauth2/token")); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT, url("https://example.com/issuer1/oauth2/revoke")); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI, url("https://example.com/issuer1/oauth2/jwks")); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED, Collections.singletonList("openid")); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, Collections.singletonList("code")); + claims.put("some-claim", "some-value"); + + OAuth2AuthorizationServerConfiguration authorizationServerConfiguration = OAuth2AuthorizationServerConfiguration.withClaims(claims).build(); + + assertThat(authorizationServerConfiguration.getIssuer()).isEqualTo(url("https://example.com/issuer1")); + assertThat(authorizationServerConfiguration.getAuthorizationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/authorize")); + assertThat(authorizationServerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/token")); + assertThat(authorizationServerConfiguration.getTokenRevocationEndpoint()).isEqualTo(url("https://example.com/issuer1/oauth2/revoke")); + assertThat(authorizationServerConfiguration.getJwkSetUri()).isEqualTo(url("https://example.com/issuer1/oauth2/jwks")); + assertThat(authorizationServerConfiguration.getScopes()).containsExactly("openid"); + assertThat(authorizationServerConfiguration.getResponseTypes()).containsExactly("code"); + assertThat(authorizationServerConfiguration.getGrantTypes()).isNull(); + assertThat(authorizationServerConfiguration.getTokenEndpointAuthenticationMethods()).isNull(); + assertThat(authorizationServerConfiguration.getTokenRevocationEndpointAuthenticationMethods()).isNull(); + assertThat(authorizationServerConfiguration.getCodeChallengeMethods()).isNull(); + assertThat(authorizationServerConfiguration.getClaimAsString("some-claim")).isEqualTo("some-value"); + } + + @Test + public void withClaimsWhenNullThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> OAuth2AuthorizationServerConfiguration.withClaims(null)) + .withMessage("claims cannot be empty"); + } + + @Test + public void withClaimsWhenMissingRequiredClaimsThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> OAuth2AuthorizationServerConfiguration.withClaims(Collections.emptyMap())) + .withMessage("claims cannot be empty"); + } + + @Test + public void buildWhenCalledTwiceThenGeneratesTwoConfigurations() { + OAuth2AuthorizationServerConfiguration first = this.minimalConfigurationBuilder + .grantType("client_credentials") + .build(); + + OAuth2AuthorizationServerConfiguration second = this.minimalConfigurationBuilder + .claims((claims) -> + { + LinkedHashSet newGrantTypes = new LinkedHashSet<>(); + newGrantTypes.add("authorization_code"); + newGrantTypes.add("custom_grant"); + claims.put(OAuth2AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED, newGrantTypes); + } + ) + .build(); + + assertThat(first.getGrantTypes()).containsExactly("client_credentials"); + assertThat(second.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "custom_grant"); + } + + @Test + public void buildWhenEmptyClaimsThenOmitted() { + OAuth2AuthorizationServerConfiguration authorizationServerConfiguration = this.minimalConfigurationBuilder + .claim("some-claim", Collections.emptyList()) + .claims(claims -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED, Collections.emptyList())) + .build(); + + assertThat(authorizationServerConfiguration.getClaimAsStringList("some-claim")).isNull(); + assertThat(authorizationServerConfiguration.getClaimAsStringList(OAuth2AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED)).isNull(); + } + + @Test + public void buildWhenMissingIssuerThenThrowsIllegalArgumentException() { + Builder builder = this.minimalConfigurationBuilder + .claims((claims) -> claims.remove(OAuth2AuthorizationServerMetadataClaimNames.ISSUER)); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("issuer cannot be null"); + } + + @Test + public void buildWhenIssuerIsNotAnUrlThenThrowsIllegalArgumentException() { + Builder builder = this.minimalConfigurationBuilder + .claims((claims) -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.ISSUER, "not an url")); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageStartingWith("issuer must be a valid URL"); + } + + @Test + public void buildWhenMissingAuthorizationEndpointThenThrowsIllegalArgumentException() { + Builder builder = this.minimalConfigurationBuilder + .claims((claims) -> claims.remove(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT)); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("authorizationEndpoint cannot be null"); + } + + @Test + public void buildWhenAuthorizationEndpointIsNotAnUrlThenThrowsIllegalArgumentException() { + Builder builder = this.minimalConfigurationBuilder + .claims((claims) -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT, "not an url")); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageStartingWith("authorizationEndpoint must be a valid URL"); + } + + @Test + public void buildWhenMissingTokenEndpointThenThrowsIllegalArgumentException() { + Builder builder = this.minimalConfigurationBuilder + .claims((claims) -> claims.remove(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT)); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("tokenEndpoint cannot be null"); + } + + @Test + public void buildWhenTokenEndpointIsNotAnUrlThenThrowsIllegalArgumentException() { + Builder builder = this.minimalConfigurationBuilder + .claims((claims) -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT, "not an url")); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageStartingWith("tokenEndpoint must be a valid URL"); + } + + @Test + public void buildWhenMissingJwksUriThenThrowsIllegalArgumentException() { + Builder builder = this.minimalConfigurationBuilder + .claims((claims) -> claims.remove(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI)); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("jwksUri cannot be null"); + } + + @Test + public void buildWhenJwksUriIsNotAnUrlThenThrowsIllegalArgumentException() { + Builder builder = this.minimalConfigurationBuilder + .claims((claims) -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI, "not an url")); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageStartingWith("jwksUri must be a valid URL"); + } + + @Test + public void buildWhenMissingResponseTypesThenThrowsIllegalArgumentException() { + Builder builder = this.minimalConfigurationBuilder + .claims((claims) -> claims.remove(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED)); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("responseTypes cannot be null"); + } + + @Test + public void buildWhenResponseTypesNotListThenThrowIllegalArgumentException() { + Builder builder = this.minimalConfigurationBuilder + .claims((claims) -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, "not-a-list")); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageStartingWith("responseTypes must be of type List"); + } + + @Test + public void buildWhenResponseTypesEmptyListThenThrowIllegalArgumentException() { + Builder builder = this.minimalConfigurationBuilder + .claims((claims) -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, Collections.emptyList())); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("responseTypes cannot be empty"); + } + + @Test + public void buildWhenInvalidTokenRevocationEndpointThenThrowsIllegalArgumentException() { + Builder builder = this.minimalConfigurationBuilder + .tokenRevocationEndpoint("not a valid URL"); + + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("tokenRevocationEndpoint must be a valid URL"); + } + + @Test + public void responseTypesWhenAddingOrRemovingThenCorrectValues() { + OAuth2AuthorizationServerConfiguration configuration = this.minimalConfigurationBuilder + .responseType("should-be-removed") + .responseTypes(responseTypes -> { + responseTypes.clear(); + responseTypes.add("some-response-type"); + }) + .build(); + + assertThat(configuration.getResponseTypes()).containsExactly("some-response-type"); + } + + @Test + public void responseTypesWhenNotPresentAndAddingThenCorrectValues() { + OAuth2AuthorizationServerConfiguration configuration = this.minimalConfigurationBuilder + .claims(claims -> claims.remove(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED)) + .responseTypes(responseTypes -> responseTypes.add("some-response-type")) + .build(); + + assertThat(configuration.getResponseTypes()).containsExactly("some-response-type"); + } + + @Test + public void scopesWhenAddingOrRemovingThenCorrectValues() { + OAuth2AuthorizationServerConfiguration configuration = this.minimalConfigurationBuilder + .scope("should-be-removed") + .scopes(scopes -> { + scopes.clear(); + scopes.add("some-scope"); + }) + .build(); + + assertThat(configuration.getScopes()).containsExactly("some-scope"); + } + + @Test + public void grantTypesWhenAddingOrRemovingThenCorrectValues() { + OAuth2AuthorizationServerConfiguration configuration = this.minimalConfigurationBuilder + .grantType("should-be-removed") + .grantTypes(grantTypes -> { + grantTypes.clear(); + grantTypes.add("some-grant-type"); + }) + .build(); + + assertThat(configuration.getGrantTypes()).containsExactly("some-grant-type"); + } + + @Test + public void tokenEndpointAuthenticationMethodsWhenAddingOrRemovingThenCorrectValues() { + OAuth2AuthorizationServerConfiguration configuration = this.minimalConfigurationBuilder + .tokenEndpointAuthenticationMethod("should-be-removed") + .tokenEndpointAuthenticationMethods(authMethods -> { + authMethods.clear(); + authMethods.add("some-authentication-method"); + }) + .build(); + + assertThat(configuration.getTokenEndpointAuthenticationMethods()).containsExactly("some-authentication-method"); + } + + @Test + public void tokenRevocationEndpointAuthenticationMethodsWhenAddingOrRemovingThenCorrectValues() { + OAuth2AuthorizationServerConfiguration configuration = this.minimalConfigurationBuilder + .tokenRevocationEndpointAuthenticationMethod("should-be-removed") + .tokenRevocationEndpointAuthenticationMethods(authMethods -> { + authMethods.clear(); + authMethods.add("some-authentication-method"); + }) + .build(); + + assertThat(configuration.getTokenRevocationEndpointAuthenticationMethods()).containsExactly("some-authentication-method"); + } + + @Test + public void codeChallengeMethodsMethodsWhenAddingOrRemovingThenCorrectValues() { + OAuth2AuthorizationServerConfiguration configuration = this.minimalConfigurationBuilder + .codeChallengeMethod("should-be-removed") + .codeChallengeMethods(codeChallengeMethods -> { + codeChallengeMethods.clear(); + codeChallengeMethods.add("some-authentication-method"); + }) + .build(); + + assertThat(configuration.getCodeChallengeMethods()).containsExactly("some-authentication-method"); + } + + @Test + public void claimWhenNameIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> OAuth2AuthorizationServerConfiguration.builder().claim(null, "value")) + .withMessage("name cannot be empty"); + } + + @Test + public void claimWhenValueIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> OAuth2AuthorizationServerConfiguration.builder().claim("claim-name", null)) + .withMessage("value cannot be null"); + } + + @Test + public void claimsWhenRemovingClaimThenNotPresent() { + OAuth2AuthorizationServerConfiguration configuration = + this.minimalConfigurationBuilder + .grantType("some-grant-type") + .claims((claims) -> claims.remove(OAuth2AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED)) + .build(); + assertThat(configuration.getGrantTypes()).isNull(); + } + + @Test + public void claimsWhenAddingClaimThenPresent() { + OAuth2AuthorizationServerConfiguration configuration = + this.minimalConfigurationBuilder + .claims((claims) -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED, "authorization_code")) + .build(); + assertThat(configuration.getGrantTypes()).containsExactly("authorization_code"); + } + + private static URL url(String urlString) { + try { + return new URL(urlString); + } catch (Exception ex) { + throw new IllegalArgumentException("urlString must be a valid URL and valid URI"); + } + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/endpoint/PkceCodeChallengeMethod2Test.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/endpoint/PkceCodeChallengeMethod2Test.java new file mode 100644 index 000000000..e31f04cb0 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/endpoint/PkceCodeChallengeMethod2Test.java @@ -0,0 +1,41 @@ +/* + * Copyright 2020 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.core.endpoint; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * TODO + * This class is temporary and will be removed after upgrading to Spring Security 5.5.0 GA. + * + * Tests for {@link PkceCodeChallengeMethod2}. + * + * @author Daniel Garnier-Moiroux + */ +public class PkceCodeChallengeMethod2Test { + + @Test + public void getValueWhenCodeChallengeMethodPlainThenReturnPlain() { + assertThat(PkceCodeChallengeMethod2.PLAIN.getValue()).isEqualTo("plain"); + } + + @Test + public void getValueWhenCodeChallengeMethodS256ThenReturnS256() { + assertThat(PkceCodeChallengeMethod2.S256.getValue()).isEqualTo("S256"); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2AuthorizationServerConfigurationHttpMessageConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2AuthorizationServerConfigurationHttpMessageConverterTests.java new file mode 100644 index 000000000..d96147aa7 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2AuthorizationServerConfigurationHttpMessageConverterTests.java @@ -0,0 +1,218 @@ +/* + * Copyright 2020 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.core.http.converter; + + +import org.junit.Test; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.mock.http.MockHttpOutputMessage; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationServerConfiguration; + +import java.net.URL; +import java.util.Arrays; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link OAuth2AuthorizationServerConfigurationHttpMessageConverter} + * + * @author Daniel Garnier-Moiroux + */ +public class OAuth2AuthorizationServerConfigurationHttpMessageConverterTests { + private final OAuth2AuthorizationServerConfigurationHttpMessageConverter messageConverter = new OAuth2AuthorizationServerConfigurationHttpMessageConverter(); + + @Test + public void supportsWhenOAuth2AuthorizationServerConfigurationThenTrue() { + assertThat(this.messageConverter.supports(OAuth2AuthorizationServerConfiguration.class)).isTrue(); + } + + @Test + public void setAuthorizationServerConfigurationParametersConverterWhenConverterIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.messageConverter.setAuthorizationServerConfigurationParametersConverter(null)); + } + + @Test + public void setAuthorizationServerConfigurationConverterWhenConverterIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.messageConverter.setAuthorizationServerConfigurationConverter(null)); + } + + @Test + public void readInternalWhenRequiredParametersThenSuccess() throws Exception { + // @formatter:off + String serverConfigurationResponse = "{\n" + + " \"issuer\": \"https://example.com/issuer1\",\n" + + " \"authorization_endpoint\": \"https://example.com/issuer1/oauth2/authorize\",\n" + + " \"token_endpoint\": \"https://example.com/issuer1/oauth2/token\",\n" + + " \"jwks_uri\": \"https://example.com/issuer1/oauth2/jwks\",\n" + + " \"response_types_supported\": [\"code\"]\n" + + "}\n"; + // @formatter:on + MockClientHttpResponse response = new MockClientHttpResponse(serverConfigurationResponse.getBytes(), HttpStatus.OK); + OAuth2AuthorizationServerConfiguration serverConfiguration = this.messageConverter + .readInternal(OAuth2AuthorizationServerConfiguration.class, response); + + assertThat(serverConfiguration.getIssuer()).isEqualTo(new URL("https://example.com/issuer1")); + assertThat(serverConfiguration.getAuthorizationEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/authorize")); + assertThat(serverConfiguration.getTokenEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/token")); + assertThat(serverConfiguration.getJwkSetUri()).isEqualTo(new URL("https://example.com/issuer1/oauth2/jwks")); + assertThat(serverConfiguration.getResponseTypes()).containsExactly("code"); + assertThat(serverConfiguration.getScopes()).isNull(); + assertThat(serverConfiguration.getGrantTypes()).isNull(); + assertThat(serverConfiguration.getTokenEndpointAuthenticationMethods()).isNull(); + assertThat(serverConfiguration.getCodeChallengeMethods()).isNull(); + assertThat(serverConfiguration.getTokenRevocationEndpoint()).isNull(); + assertThat(serverConfiguration.getTokenRevocationEndpointAuthenticationMethods()).isNull(); + } + + @Test + public void readInternalWhenValidParametersThenSuccess() throws Exception { + // @formatter:off + String serverConfigurationResponse = "{\n" + + " \"issuer\": \"https://example.com/issuer1\",\n" + + " \"authorization_endpoint\": \"https://example.com/issuer1/oauth2/authorize\",\n" + + " \"token_endpoint\": \"https://example.com/issuer1/oauth2/token\",\n" + + " \"revocation_endpoint\": \"https://example.com/issuer1/oauth2/revoke\",\n" + + " \"jwks_uri\": \"https://example.com/issuer1/oauth2/jwks\",\n" + + " \"response_types_supported\": [\"code\"],\n" + + " \"grant_types_supported\": [\"authorization_code\", \"client_credentials\"],\n" + + " \"scopes_supported\": [\"openid\"],\n" + + " \"token_endpoint_auth_methods_supported\": [\"client_secret_basic\"],\n" + + " \"revocation_endpoint_auth_methods_supported\": [\"client_secret_basic\"],\n" + + " \"code_challenge_methods_supported\": [\"plain\",\"S256\"],\n" + + " \"custom_claim\": \"value\",\n" + + " \"custom_collection_claim\": [\"value1\", \"value2\"]\n" + + "}\n"; + // @formatter:on + MockClientHttpResponse response = new MockClientHttpResponse(serverConfigurationResponse.getBytes(), HttpStatus.OK); + OAuth2AuthorizationServerConfiguration serverConfiguration = this.messageConverter + .readInternal(OAuth2AuthorizationServerConfiguration.class, response); + + assertThat(serverConfiguration.getClaims()).hasSize(13); + assertThat(serverConfiguration.getIssuer()).isEqualTo(new URL("https://example.com/issuer1")); + assertThat(serverConfiguration.getAuthorizationEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/authorize")); + assertThat(serverConfiguration.getTokenEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/token")); + assertThat(serverConfiguration.getTokenRevocationEndpoint()).isEqualTo(new URL("https://example.com/issuer1/oauth2/revoke")); + assertThat(serverConfiguration.getJwkSetUri()).isEqualTo(new URL("https://example.com/issuer1/oauth2/jwks")); + assertThat(serverConfiguration.getResponseTypes()).containsExactly("code"); + assertThat(serverConfiguration.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "client_credentials"); + assertThat(serverConfiguration.getScopes()).containsExactly("openid"); + assertThat(serverConfiguration.getTokenEndpointAuthenticationMethods()).containsExactly("client_secret_basic"); + assertThat(serverConfiguration.getTokenRevocationEndpointAuthenticationMethods()).containsExactly("client_secret_basic"); + assertThat(serverConfiguration.getCodeChallengeMethods()).containsExactlyInAnyOrder("plain", "S256"); + assertThat(serverConfiguration.getClaimAsString("custom_claim")).isEqualTo("value"); + assertThat(serverConfiguration.getClaimAsStringList("custom_collection_claim")).containsExactlyInAnyOrder("value1", "value2"); + } + + @Test + public void readInternalWhenFailingConverterThenThrowException() { + String errorMessage = "this is not a valid converter"; + this.messageConverter.setAuthorizationServerConfigurationConverter(source -> { + throw new RuntimeException(errorMessage); + }); + MockClientHttpResponse response = new MockClientHttpResponse("{}".getBytes(), HttpStatus.OK); + + assertThatExceptionOfType(HttpMessageNotReadableException.class) + .isThrownBy(() -> this.messageConverter.readInternal(OAuth2AuthorizationServerConfiguration.class, response)) + .withMessageContaining("An error occurred reading the OAuth 2.0 Authorization Server Configuration") + .withMessageContaining(errorMessage); + } + + @Test + public void readInternalWhenInvalidOAuth2AuthorizationServerConfigurationThenThrowException() { + String providerConfigurationResponse = "{ \"issuer\": null }"; + MockClientHttpResponse response = new MockClientHttpResponse(providerConfigurationResponse.getBytes(), HttpStatus.OK); + + assertThatExceptionOfType(HttpMessageNotReadableException.class) + .isThrownBy(() -> this.messageConverter.readInternal(OAuth2AuthorizationServerConfiguration.class, response)) + .withMessageContaining("An error occurred reading the OAuth 2.0 Authorization Server Configuration") + .withMessageContaining("issuer cannot be null"); + } + + @Test + public void writeInternalWhenOAuth2AuthorizationServerConfigurationThenSuccess() { + OAuth2AuthorizationServerConfiguration serverConfiguration = + OAuth2AuthorizationServerConfiguration + .builder() + .issuer("https://example.com/issuer1") + .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") + .tokenEndpoint("https://example.com/issuer1/oauth2/token") + .tokenRevocationEndpoint("https://example.com/issuer1/oauth2/revoke") + .jwkSetUri("https://example.com/issuer1/oauth2/jwks") + .scope("openid") + .responseType("code") + .grantType("authorization_code") + .grantType("client_credentials") + .tokenEndpointAuthenticationMethod("client_secret_basic") + .tokenRevocationEndpointAuthenticationMethod("client_secret_basic") + .codeChallengeMethod("plain") + .codeChallengeMethod("S256") + .claim("custom_claim", "value") + .claim("custom_collection_claim", Arrays.asList("value1", "value2")) + .build(); + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + + this.messageConverter.writeInternal(serverConfiguration, outputMessage); + + String serverConfigurationResponse = outputMessage.getBodyAsString(); + assertThat(serverConfigurationResponse).contains("\"issuer\":\"https://example.com/issuer1\""); + assertThat(serverConfigurationResponse).contains("\"authorization_endpoint\":\"https://example.com/issuer1/oauth2/authorize\""); + assertThat(serverConfigurationResponse).contains("\"token_endpoint\":\"https://example.com/issuer1/oauth2/token\""); + assertThat(serverConfigurationResponse).contains("\"revocation_endpoint\":\"https://example.com/issuer1/oauth2/revoke\""); + assertThat(serverConfigurationResponse).contains("\"jwks_uri\":\"https://example.com/issuer1/oauth2/jwks\""); + assertThat(serverConfigurationResponse).contains("\"scopes_supported\":[\"openid\"]"); + assertThat(serverConfigurationResponse).contains("\"response_types_supported\":[\"code\"]"); + assertThat(serverConfigurationResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\"]"); + assertThat(serverConfigurationResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\"]"); + assertThat(serverConfigurationResponse).contains("\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\"]"); + assertThat(serverConfigurationResponse).contains("\"code_challenge_methods_supported\":[\"plain\",\"S256\"]"); + assertThat(serverConfigurationResponse).contains("\"custom_claim\":\"value\""); + assertThat(serverConfigurationResponse).contains("\"custom_collection_claim\":[\"value1\",\"value2\"]"); + + } + + @Test + public void writeInternalWhenWriteFailsThenThrowsException() { + String errorMessage = "this is not a valid converter"; + Converter> failingConverter = + source -> { + throw new RuntimeException(errorMessage); + }; + this.messageConverter.setAuthorizationServerConfigurationParametersConverter(failingConverter); + + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + OAuth2AuthorizationServerConfiguration serverConfiguration = + OAuth2AuthorizationServerConfiguration + .builder() + .issuer("https://example.com/issuer1") + .authorizationEndpoint("https://example.com/issuer1/oauth2/authorize") + .tokenEndpoint("https://example.com/issuer1/oauth2/token") + .jwkSetUri("https://example.com/issuer1/oauth2/jwks") + .responseType("code") + .build(); + + assertThatExceptionOfType(HttpMessageNotWritableException.class) + .isThrownBy(() -> this.messageConverter.writeInternal(serverConfiguration, outputMessage)) + .withMessageContaining("An error occurred writing the OAuth 2.0 Authorization Server Configuration") + .withMessageContaining(errorMessage); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/OidcProviderConfigurationTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/OidcProviderConfigurationTests.java index 791d31022..ebc979cff 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/OidcProviderConfigurationTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/OidcProviderConfigurationTests.java @@ -25,7 +25,7 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; /** * Tests for {@link OidcProviderConfiguration}. @@ -157,15 +157,16 @@ public void buildWhenClaimsProvidedWithUrlsThenCreated() { @Test public void withClaimsWhenNullThenThrowIllegalArgumentException() { - assertThatThrownBy(() -> OidcProviderConfiguration.withClaims(null)) - .isInstanceOf(IllegalArgumentException.class); + assertThatIllegalArgumentException().isThrownBy(() -> OidcProviderConfiguration.withClaims(null)) + .isInstanceOf(IllegalArgumentException.class) + .withMessage("claims cannot be empty"); } @Test public void withClaimsWhenMissingRequiredClaimsThenThrowIllegalArgumentException() { - assertThatThrownBy(() -> OidcProviderConfiguration.withClaims(Collections.emptyMap())) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("claims cannot be empty"); + assertThatIllegalArgumentException() + .isThrownBy(() -> OidcProviderConfiguration.withClaims(Collections.emptyMap())) + .withMessage("claims cannot be empty"); } @Test @@ -189,14 +190,25 @@ public void buildWhenCalledTwiceThenGeneratesTwoConfigurations() { assertThat(second.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "custom_grant"); } + @Test + public void buildWhenEmptyClaimsThenOmitted() { + OidcProviderConfiguration providerConfiguration = this.minimalConfigurationBuilder + .claim("some-claim", Collections.emptyList()) + .claims(claims -> claims.put(OidcProviderMetadataClaimNames.GRANT_TYPES_SUPPORTED, Collections.emptyList())) + .build(); + + assertThat(providerConfiguration.getClaimAsStringList("some-claim")).isNull(); + assertThat(providerConfiguration.getClaimAsStringList(OidcProviderMetadataClaimNames.GRANT_TYPES_SUPPORTED)).isNull(); + } + @Test public void buildWhenMissingIssuerThenThrowIllegalArgumentException() { OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder .claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.ISSUER)); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("issuer cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("issuer cannot be null"); } @Test @@ -204,9 +216,9 @@ public void buildWhenIssuerNotUrlThenThrowIllegalArgumentException() { OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder .claims((claims) -> claims.put(OidcProviderMetadataClaimNames.ISSUER, "not an url")); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("issuer must be a valid URL"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("issuer must be a valid URL"); } @Test @@ -214,9 +226,9 @@ public void buildWhenMissingAuthorizationEndpointThenThrowIllegalArgumentExcepti OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder .claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT)); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("authorizationEndpoint cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("authorizationEndpoint cannot be null"); } @Test @@ -224,9 +236,9 @@ public void buildWhenAuthorizationEndpointNotUrlThenThrowIllegalArgumentExceptio OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder .claims((claims) -> claims.put(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT, "not an url")); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageStartingWith("authorizationEndpoint must be a valid URL"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageStartingWith("authorizationEndpoint must be a valid URL"); } @Test @@ -234,9 +246,9 @@ public void buildWhenMissingTokenEndpointThenThrowIllegalArgumentException() { OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder .claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT)); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("tokenEndpoint cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("tokenEndpoint cannot be null"); } @Test @@ -244,9 +256,9 @@ public void buildWhenTokenEndpointNotUrlThenThrowIllegalArgumentException() { OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder .claims((claims) -> claims.put(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT, "not an url")); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageStartingWith("tokenEndpoint must be a valid URL"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageStartingWith("tokenEndpoint must be a valid URL"); } @Test @@ -254,9 +266,9 @@ public void buildWhenMissingJwksUriThenThrowIllegalArgumentException() { OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder .claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.JWKS_URI)); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("jwksUri cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("jwksUri cannot be null"); } @Test @@ -264,9 +276,9 @@ public void buildWhenJwksUriNotUrlThenThrowIllegalArgumentException() { OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder .claims((claims) -> claims.put(OidcProviderMetadataClaimNames.JWKS_URI, "not an url")); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageStartingWith("jwksUri must be a valid URL"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageStartingWith("jwksUri must be a valid URL"); } @Test @@ -274,9 +286,9 @@ public void buildWhenMissingResponseTypesThenThrowIllegalArgumentException() { OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder .claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED)); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("responseTypes cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("responseTypes cannot be null"); } @Test @@ -287,9 +299,9 @@ public void buildWhenResponseTypesNotListThenThrowIllegalArgumentException() { claims.put(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, "code"); }); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("responseTypes must be of type List"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageContaining("responseTypes must be of type List"); } @Test @@ -300,9 +312,9 @@ public void buildWhenResponseTypesEmptyListThenThrowIllegalArgumentException() { claims.put(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, Collections.emptyList()); }); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("responseTypes cannot be empty"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageContaining("responseTypes cannot be empty"); } @Test @@ -310,9 +322,9 @@ public void buildWhenMissingSubjectTypesThenThrowIllegalArgumentException() { OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder .claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED)); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("subjectTypes cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("subjectTypes cannot be null"); } @Test @@ -323,9 +335,9 @@ public void buildWhenSubjectTypesNotListThenThrowIllegalArgumentException() { claims.put(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED, "public"); }); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("subjectTypes must be of type List"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageContaining("subjectTypes must be of type List"); } @Test @@ -336,9 +348,9 @@ public void buildWhenSubjectTypesEmptyListThenThrowIllegalArgumentException() { claims.put(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED, Collections.emptyList()); }); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("subjectTypes cannot be empty"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageContaining("subjectTypes cannot be empty"); } @Test @@ -346,9 +358,9 @@ public void buildWhenMissingIdTokenSigningAlgorithmsThenThrowIllegalArgumentExce OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder .claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED)); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("idTokenSigningAlgorithms cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessage("idTokenSigningAlgorithms cannot be null"); } @Test @@ -359,9 +371,9 @@ public void buildWhenIdTokenSigningAlgorithmsNotListThenThrowIllegalArgumentExce claims.put(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED, "RS256"); }); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("idTokenSigningAlgorithms must be of type List"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageContaining("idTokenSigningAlgorithms must be of type List"); } @Test @@ -372,9 +384,9 @@ public void buildWhenIdTokenSigningAlgorithmsEmptyListThenThrowIllegalArgumentEx claims.put(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED, Collections.emptyList()); }); - assertThatThrownBy(builder::build) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("idTokenSigningAlgorithms cannot be empty"); + assertThatIllegalArgumentException() + .isThrownBy(builder::build) + .withMessageContaining("idTokenSigningAlgorithms cannot be empty"); } @Test @@ -467,16 +479,16 @@ public void tokenEndpointAuthenticationMethodsWhenAddingOrRemovingThenCorrectVal @Test public void claimWhenNameIsNullThenThrowIllegalArgumentException() { - assertThatThrownBy(() -> OidcProviderConfiguration.builder().claim(null, "value")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("name cannot be empty"); + assertThatIllegalArgumentException() + .isThrownBy(() -> OidcProviderConfiguration.builder().claim(null, "value")) + .withMessage("name cannot be empty"); } @Test public void claimWhenValueIsNullThenThrowIllegalArgumentException() { - assertThatThrownBy(() -> OidcProviderConfiguration.builder().claim("claim-name", null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("value cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(() -> OidcProviderConfiguration.builder().claim("claim-name", null)) + .withMessage("value cannot be null"); } @Test diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcProviderConfigurationHttpMessageConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcProviderConfigurationHttpMessageConverterTests.java index 2d4f82e9d..787250344 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcProviderConfigurationHttpMessageConverterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcProviderConfigurationHttpMessageConverterTests.java @@ -31,7 +31,6 @@ 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.assertj.core.api.Assertions.assertThatThrownBy; /** * Tests for {@link OidcProviderConfigurationHttpMessageConverter} @@ -205,9 +204,9 @@ public void writeInternalWhenWriteFailsThenThrowsException() { MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); - assertThatThrownBy(() -> this.messageConverter.writeInternal(providerConfiguration, outputMessage)) - .isInstanceOf(HttpMessageNotWritableException.class) - .hasMessageContaining("An error occurred writing the OpenID Provider Configuration") - .hasMessageContaining(errorMessage); + assertThatExceptionOfType(HttpMessageNotWritableException.class) + .isThrownBy(() -> this.messageConverter.writeInternal(providerConfiguration, outputMessage)) + .withMessageContaining("An error occurred writing the OpenID Provider Configuration") + .withMessageContaining(errorMessage); } } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettingsTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettingsTests.java index 1c9ae660b..3997f2cf5 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettingsTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettingsTests.java @@ -18,7 +18,7 @@ import org.junit.Test; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; /** * Tests for {@link ProviderSettings}. @@ -78,48 +78,48 @@ public void settingWhenCustomThenReturnAllSettings() { @Test public void issuerWhenNullThenThrowIllegalArgumentException() { ProviderSettings settings = new ProviderSettings(); - assertThatThrownBy(() -> settings.issuer(null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("value cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(() -> settings.issuer(null)) + .withMessage("value cannot be null"); } @Test public void authorizationEndpointWhenNullThenThrowIllegalArgumentException() { ProviderSettings settings = new ProviderSettings(); - assertThatThrownBy(() -> settings.authorizationEndpoint(null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("value cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(() -> settings.authorizationEndpoint(null)) + .withMessage("value cannot be null"); } @Test public void tokenEndpointWhenNullThenThrowIllegalArgumentException() { ProviderSettings settings = new ProviderSettings(); - assertThatThrownBy(() -> settings.tokenEndpoint(null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("value cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(() -> settings.tokenEndpoint(null)) + .withMessage("value cannot be null"); } @Test public void tokenRevocationEndpointWhenNullThenThrowIllegalArgumentException() { ProviderSettings settings = new ProviderSettings(); - assertThatThrownBy(() -> settings.tokenRevocationEndpoint(null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("value cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(() -> settings.tokenRevocationEndpoint(null)) + .withMessage("value cannot be null"); } @Test public void tokenIntrospectionEndpointWhenNullThenThrowIllegalArgumentException() { ProviderSettings settings = new ProviderSettings(); - assertThatThrownBy(() -> settings.tokenIntrospectionEndpoint(null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("value cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(() -> settings.tokenIntrospectionEndpoint(null)) + .withMessage("value cannot be null"); } @Test public void jwksEndpointWhenNullThenThrowIllegalArgumentException() { ProviderSettings settings = new ProviderSettings(); - assertThatThrownBy(() -> settings.jwkSetEndpoint(null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("value cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(() -> settings.jwkSetEndpoint(null)) + .withMessage("value cannot be null"); } } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java index 75df4633f..f27e80063 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java @@ -26,7 +26,7 @@ import javax.servlet.http.HttpServletResponse; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -41,9 +41,9 @@ public class OidcProviderConfigurationEndpointFilterTests { @Test public void constructorWhenProviderSettingsNullThenThrowIllegalArgumentException() { - assertThatThrownBy(() -> new OidcProviderConfigurationEndpointFilter(null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("providerSettings cannot be null"); + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcProviderConfigurationEndpointFilter(null)) + .withMessage("providerSettings cannot be null"); } @Test @@ -129,8 +129,8 @@ public void doFilterWhenProviderSettingsWithInvalidIssuerThenThrowIllegalArgumen MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = mock(FilterChain.class); - assertThatThrownBy(() -> filter.doFilter(request, response, filterChain)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("issuer must be a valid URL"); + assertThatIllegalArgumentException() + .isThrownBy(() -> filter.doFilter(request, response, filterChain)) + .withMessage("issuer must be a valid URL"); } } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerConfigurationEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerConfigurationEndpointFilterTests.java new file mode 100644 index 000000000..321227918 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerConfigurationEndpointFilterTests.java @@ -0,0 +1,141 @@ +/* + * Copyright 2020 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.authorization.web; + + +import org.junit.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.oauth2.server.authorization.config.ProviderSettings; + +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Tests for {@link OAuth2AuthorizationServerConfigurationEndpointFilter}. + * + * @author Daniel Garnier-Moiroux + */ +public class OAuth2AuthorizationServerConfigurationEndpointFilterTests { + + @Test + public void constructorWhenProviderSettingsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2AuthorizationServerConfigurationEndpointFilter(null)) + .withMessage("providerSettings cannot be null"); + } + + @Test + public void doFilterWhenNotAuthorizationServerConfigurationRequestThenNotProcessed() throws Exception { + OAuth2AuthorizationServerConfigurationEndpointFilter filter = + new OAuth2AuthorizationServerConfigurationEndpointFilter(new ProviderSettings().issuer("https://example.com")); + + String requestUri = "/path"; + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); + request.setServletPath(requestUri); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class)); + } + + @Test + public void doFilterWhenAuthorizationServerConfigurationRequestPostThenNotProcessed() throws Exception { + OAuth2AuthorizationServerConfigurationEndpointFilter filter = + new OAuth2AuthorizationServerConfigurationEndpointFilter(new ProviderSettings().issuer("https://example.com")); + + String requestUri = OAuth2AuthorizationServerConfigurationEndpointFilter.DEFAULT_OAUTH2_AUTHORIZATION_SERVER_CONFIGURATION_ENDPOINT_URI; + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + request.setServletPath(requestUri); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class)); + } + + @Test + public void doFilterWhenAuthorizationServerConfigurationRequestThenAuthorizationServerConfigurationResponse() throws Exception { + String authorizationEndpoint = "/oauth2/v1/authorize"; + String tokenEndpoint = "/oauth2/v1/token"; + String tokenRevocationEndpoint = "/oauth2/v1/revoke"; + String jwkSetEndpoint = "/oauth2/v1/jwks"; + + ProviderSettings providerSettings = new ProviderSettings() + .issuer("https://example.com/issuer1") + .authorizationEndpoint(authorizationEndpoint) + .tokenEndpoint(tokenEndpoint) + .tokenRevocationEndpoint(tokenRevocationEndpoint) + .jwkSetEndpoint(jwkSetEndpoint); + OAuth2AuthorizationServerConfigurationEndpointFilter filter = + new OAuth2AuthorizationServerConfigurationEndpointFilter(providerSettings); + + String requestUri = OAuth2AuthorizationServerConfigurationEndpointFilter.DEFAULT_OAUTH2_AUTHORIZATION_SERVER_CONFIGURATION_ENDPOINT_URI; + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); + request.setServletPath(requestUri); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + filter.doFilter(request, response, filterChain); + + verifyNoInteractions(filterChain); + + assertThat(response.getContentType()).isEqualTo(MediaType.APPLICATION_JSON_VALUE); + String serverConfigurationResponse = response.getContentAsString(); + assertThat(serverConfigurationResponse).contains("\"issuer\":\"https://example.com/issuer1\""); + assertThat(serverConfigurationResponse).contains("\"authorization_endpoint\":\"https://example.com/issuer1/oauth2/v1/authorize\""); + assertThat(serverConfigurationResponse).contains("\"token_endpoint\":\"https://example.com/issuer1/oauth2/v1/token\""); + assertThat(serverConfigurationResponse).contains("\"revocation_endpoint\":\"https://example.com/issuer1/oauth2/v1/revoke\""); + assertThat(serverConfigurationResponse).contains("\"jwks_uri\":\"https://example.com/issuer1/oauth2/v1/jwks\""); + assertThat(serverConfigurationResponse).contains("\"scopes_supported\":[\"openid\"]"); + assertThat(serverConfigurationResponse).contains("\"response_types_supported\":[\"code\"]"); + assertThat(serverConfigurationResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\",\"refresh_token\"]"); + assertThat(serverConfigurationResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\"]"); + assertThat(serverConfigurationResponse).contains("\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\"]"); + assertThat(serverConfigurationResponse).contains("\"code_challenge_methods_supported\":[\"plain\",\"S256\"]"); + } + + @Test + public void doFilterWhenProviderSettingsWithInvalidIssuerThenThrowIllegalArgumentException() { + ProviderSettings providerSettings = new ProviderSettings() + .issuer("https://this is an invalid URL"); + OAuth2AuthorizationServerConfigurationEndpointFilter filter = + new OAuth2AuthorizationServerConfigurationEndpointFilter(providerSettings); + + String requestUri = OAuth2AuthorizationServerConfigurationEndpointFilter.DEFAULT_OAUTH2_AUTHORIZATION_SERVER_CONFIGURATION_ENDPOINT_URI; + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); + request.setServletPath(requestUri); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + + assertThatIllegalArgumentException() + .isThrownBy(() -> filter.doFilter(request, response, filterChain)) + .withMessage("issuer must be a valid URL"); + } +}