diff --git a/oauth2-authorization-server/spring-security-oauth2-authorization-server.gradle b/oauth2-authorization-server/spring-security-oauth2-authorization-server.gradle index 2e65b34ca..3bede36fa 100644 --- a/oauth2-authorization-server/spring-security-oauth2-authorization-server.gradle +++ b/oauth2-authorization-server/spring-security-oauth2-authorization-server.gradle @@ -5,6 +5,7 @@ dependencies { compile 'org.springframework.security:spring-security-web' compile 'org.springframework.security:spring-security-oauth2-core' compile 'org.springframework.security:spring-security-oauth2-jose' + compile 'org.springframework.security:spring-security-oauth2-resource-server' compile springCoreDependency compile 'com.nimbusds:nimbus-jose-jwt' compile 'com.fasterxml.jackson.core:jackson-databind' @@ -15,6 +16,7 @@ dependencies { testCompile 'org.assertj:assertj-core' testCompile 'org.mockito:mockito-core' testCompile 'com.jayway.jsonpath:json-path' + testCompile 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' provided 'javax.servlet:javax.servlet-api' } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configuration/OAuth2AuthorizationServerConfiguration.java b/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configuration/OAuth2AuthorizationServerConfiguration.java index 9ae72f0c0..8cdead39f 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configuration/OAuth2AuthorizationServerConfiguration.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configuration/OAuth2AuthorizationServerConfiguration.java @@ -21,6 +21,7 @@ import org.springframework.core.annotation.Order; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -28,8 +29,8 @@ * {@link Configuration} for OAuth 2.0 Authorization Server support. * * @author Joe Grandja - * @since 0.0.1 * @see OAuth2AuthorizationServerConfigurer + * @since 0.0.1 */ @Configuration(proxyBeanMethods = false) public class OAuth2AuthorizationServerConfiguration { @@ -47,14 +48,16 @@ public static void applyDefaultSecurity(HttpSecurity http) throws Exception { new OAuth2AuthorizationServerConfigurer<>(); RequestMatcher endpointsMatcher = authorizationServerConfigurer .getEndpointsMatcher(); - http .requestMatcher(endpointsMatcher) .authorizeRequests(authorizeRequests -> - authorizeRequests.anyRequest().authenticated() - ) - .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) + authorizeRequests.anyRequest().authenticated() + ).csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) .apply(authorizationServerConfigurer); + + if (authorizationServerConfigurer.isOidcClientRegistrationEnabled()) { + http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); + } } // @formatter:on } 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 e2340f11b..d11f285ff 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 @@ -44,8 +44,10 @@ import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenIntrospectionAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenRevocationAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OidcClientRegistrationAuthenticationProvider; 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.oidc.web.OidcClientRegistrationEndpointFilter; 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; @@ -69,6 +71,7 @@ * @author Joe Grandja * @author Daniel Garnier-Moiroux * @author Gerardo Roza + * @author Ovidiu Popa * @since 0.0.1 * @see AbstractHttpConfigurer * @see RegisteredClientRepository @@ -81,6 +84,7 @@ * @see OidcProviderConfigurationEndpointFilter * @see OAuth2AuthorizationServerMetadataEndpointFilter * @see OAuth2ClientAuthenticationFilter + * @see OidcClientRegistrationEndpointFilter */ public final class OAuth2AuthorizationServerConfigurer> extends AbstractHttpConfigurer, B> { @@ -92,6 +96,7 @@ public final class OAuth2AuthorizationServerConfigurer this.authorizationEndpointMatcher.matches(request) || this.tokenEndpointMatcher.matches(request) || @@ -99,7 +104,8 @@ public final class OAuth2AuthorizationServerConfigurer exceptionHandling = builder.getConfigurer(ExceptionHandlingConfigurer.class); if (exceptionHandling != null) { exceptionHandling.defaultAuthenticationEntryPointFor( @@ -224,6 +246,9 @@ public void configure(B builder) { builder.addFilterBefore(postProcess(authorizationServerMetadataEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class); } + RegisteredClientRepository registeredClientRepository = getRegisteredClientRepository(builder); + OAuth2AuthorizationService authorizationService = getAuthorizationService(builder); + JWKSource jwkSource = getJwkSource(builder); NimbusJwkSetEndpointFilter jwkSetEndpointFilter = new NimbusJwkSetEndpointFilter( jwkSource, @@ -243,8 +268,8 @@ public void configure(B builder) { OAuth2AuthorizationEndpointFilter authorizationEndpointFilter = new OAuth2AuthorizationEndpointFilter( - getRegisteredClientRepository(builder), - getAuthorizationService(builder), + registeredClientRepository, + authorizationService, providerSettings.authorizationEndpoint()); builder.addFilterBefore(postProcess(authorizationEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class); @@ -265,6 +290,15 @@ public void configure(B builder) { authenticationManager, providerSettings.tokenRevocationEndpoint()); builder.addFilterAfter(postProcess(tokenRevocationEndpointFilter), OAuth2TokenIntrospectionEndpointFilter.class); + + if (providerSettings.isOidClientRegistrationEndpointEnabled()) { + OidcClientRegistrationEndpointFilter oidcClientRegistrationEndpointFilter = + new OidcClientRegistrationEndpointFilter( + registeredClientRepository, + authenticationManager, + providerSettings.oidcClientRegistrationEndpoint()); + builder.addFilterAfter(postProcess(oidcClientRegistrationEndpointFilter), OAuth2TokenRevocationEndpointFilter.class); + } } private void initEndpointMatchers(ProviderSettings providerSettings) { @@ -287,6 +321,9 @@ private void initEndpointMatchers(ProviderSettings providerSettings) { OidcProviderConfigurationEndpointFilter.DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI, HttpMethod.GET.name()); this.authorizationServerMetadataEndpointMatcher = new AntPathRequestMatcher( OAuth2AuthorizationServerMetadataEndpointFilter.DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI, HttpMethod.GET.name()); + this.oidcClientRegistrationEndpointMatcher = new AntPathRequestMatcher( + providerSettings.oidcClientRegistrationEndpoint(), + HttpMethod.POST.name()); } private static void validateProviderSettings(ProviderSettings providerSettings) { diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientMetadataClaimAccessor.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientMetadataClaimAccessor.java new file mode 100644 index 000000000..ad3c5bf68 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientMetadataClaimAccessor.java @@ -0,0 +1,130 @@ +/* + * Copyright 2020-2021 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.oidc; + +import org.springframework.security.oauth2.core.ClaimAccessor; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; + +import java.time.Instant; +import java.util.List; + +/** + * A {@link ClaimAccessor} for the "claims" that can be returned + * in the OpenID Client Registration Response. + * + * @author Ovidiu Popa + * @since 0.1.1 + * @see ClaimAccessor + * @see OidcClientMetadataClaimNames + * @see OidcClientRegistration + * @see 2. Client Metadata + */ +public interface OidcClientMetadataClaimAccessor extends ClaimAccessor { + + /** + * Returns the redirect URI(s) that the client may use in redirect-based flows. + * + * @return the {@code List} of redirect URI(s) + */ + default List getRedirectUris() { + return getClaimAsStringList(OidcClientMetadataClaimNames.REDIRECT_URIS); + } + + /** + * Returns the OAuth 2.0 {@code response_type} values that the client may use. + * + * @return the {@code List} of {@code response_type} + */ + default List getResponseTypes() { + return getClaimAsStringList(OidcClientMetadataClaimNames.RESPONSE_TYPES); + } + + /** + * Returns the authorization {@code grant_types} that the client may use. + * + * @return the {@code List} of authorization {@code grant_types} + */ + default List getGrantTypes() { + return getClaimAsStringList(OidcClientMetadataClaimNames.GRANT_TYPES); + } + + /** + * Returns the {@code client_name}. + * + * @return the {@code client_name} + */ + default String getClientName() { + return getClaimAsString(OidcClientMetadataClaimNames.CLIENT_NAME); + } + + /** + * Returns the scope(s) that the client may use. + * + * @return the scope(s) + */ + default String getScope() { + return getClaimAsString(OidcClientMetadataClaimNames.SCOPE); + } + + /** + * Returns the {@link ClientAuthenticationMethod authentication method} that the client may use. + * + * @return the {@link ClientAuthenticationMethod authentication method} + */ + default String getTokenEndpointAuthenticationMethod() { + return getClaimAsString(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD); + } + + /** + * Returns the {@code client_id}. + * + * @return the {@code client_id} + */ + default String getClientId() { + return getClaimAsString(OidcClientMetadataClaimNames.CLIENT_ID); + } + + /** + * Returns the {@code client_id_issued_at} timestamp. + * + * @return the {@code client_id_issued_at} timestamp + */ + default Instant getClientIdIssuedAt() { + return getClaimAsInstant(OidcClientMetadataClaimNames.CLIENT_ID_ISSUED_AT); + } + + /** + * Returns the {@code client_secret}. + * + * @return the {@code client_secret} + */ + default String getClientSecret() { + return getClaimAsString(OidcClientMetadataClaimNames.CLIENT_SECRET); + } + + /** + * Returns the {@code client_secret_expires_at} timestamp. + * + * @return the {@code client_secret_expires_at} timestamp + */ + default Instant getClientSecretExpiresAt() { + return getClaimAsInstant(OidcClientMetadataClaimNames.CLIENT_SECRET_EXPIRES_AT); + } + + + + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientMetadataClaimNames.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientMetadataClaimNames.java new file mode 100644 index 000000000..f18915e48 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientMetadataClaimNames.java @@ -0,0 +1,79 @@ +/* + * Copyright 2020-2021 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.oidc; + +/** + * The names of the "claims" defined by OpenID Client Registration 1.0 that can be returned + * in the OpenID Client Registration Response. + * + * @author Ovidiu Popa + * @since 0.1.1 + * @see 2. Client Metadata + */ +public interface OidcClientMetadataClaimNames { + + //request + /** + * {@code redirect_uris} - the redirect URI(s) that the client may use in redirect-based flows + */ + String REDIRECT_URIS = "redirect_uris"; + + /** + * {@code response_types} - the OAuth 2.0 {@code response_type} values that the client may use + */ + String RESPONSE_TYPES = "response_types"; + + /** + * {@code grant_types} - the OAuth 2.0 authorization {@code grant_types} that the client may use + */ + String GRANT_TYPES = "grant_types"; + + /** + * {@code client_name} - the {@code client_name} + */ + String CLIENT_NAME = "client_name"; + + /** + * {@code scope} - the scope(s) that the client may use + */ + String SCOPE = "scope"; + + /** + * {@code token_endpoint_auth_method} - the {@link org.springframework.security.oauth2.core.ClientAuthenticationMethod authentication method} that the client may use. + */ + String TOKEN_ENDPOINT_AUTH_METHOD = "token_endpoint_auth_method"; + + //response + /** + * {@code client_id} - the {@code client_id} + */ + String CLIENT_ID = "client_id"; + + /** + * {@code client_secret} - the {@code client_secret} + */ + String CLIENT_SECRET = "client_secret"; + + /** + * {@code client_id_issued_at} - the timestamp when the client id was issued + */ + String CLIENT_ID_ISSUED_AT = "client_id_issued_at"; + + /** + * {@code client_secret_expires_at} - the timestamp when the client secret expires + */ + String CLIENT_SECRET_EXPIRES_AT = "client_secret_expires_at"; +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientRegistration.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientRegistration.java new file mode 100644 index 000000000..fbdccf028 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientRegistration.java @@ -0,0 +1,349 @@ +/* + * Copyright 2020-2021 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.oidc; + +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.Version; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType; +import org.springframework.util.Assert; + +import java.io.Serializable; +import java.net.URI; +import java.net.URL; +import java.time.Instant; +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; + +/** + * A representation of an OpenID Client Registration Request and Response, + * which contains a set of claims defined by the + * OpenID Connect Registration 1.0 specification. + * + * @author Ovidiu Popa + * @since 0.1.1 + * @see OidcClientMetadataClaimAccessor + * @see 3.1. Client Registration Request + */ +public final class OidcClientRegistration implements OidcClientMetadataClaimAccessor, Serializable { + private static final long serialVersionUID = Version.SERIAL_VERSION_UID; + private final Map claims; + + private OidcClientRegistration(Map claims) { + this.claims = Collections.unmodifiableMap(claims); + } + + /** + * Returns the OpenID Client Registration metadata. + * + * @return a {@code Map} of the metadata values + */ + @Override + public Map getClaims() { + return this.claims; + } + + /** + * Constructs a new {@link OidcClientRegistration.Builder} with empty claims. + * + * @return the {@link OidcClientRegistration.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 + */ + public static Builder withClaims(Map claims) { + Assert.notEmpty(claims, "claims cannot be empty"); + return new Builder() + .claims(c -> c.putAll(claims)); + } + + public static class Builder { + + private final Map claims = new LinkedHashMap<>(); + + private Builder() { + } + + /** + * Add this Redirect URI to the collection of {@code redirect_uris} in the resulting + * {@link OidcClientRegistration}, REQUIRED. + * + * @param redirectUri the OAuth 2.0 {@code redirect_uri} value that client supports + * @return the {@link Builder} for further configuration + */ + public Builder redirectUri(String redirectUri) { + addClaimToClaimList(OidcClientMetadataClaimNames.REDIRECT_URIS, redirectUri); + return this; + } + + /** + * A {@code Consumer} of the Redirect URI(s) allowing the ability to add, replace, or remove. + * + * @param redirectUriConsumer a {@code Consumer} of the Redirect URI(s) + * @return the {@link Builder} for further configuration + */ + public Builder redirectUris(Consumer> redirectUriConsumer) { + acceptClaimValues(OidcClientMetadataClaimNames.REDIRECT_URIS, redirectUriConsumer); + return this; + } + + /** + * Add this Response Type to the collection of {@code response_types} in the resulting + * {@link OidcClientRegistration}, OPTIONAL. + * + * @param responseType the OAuth 2.0 {@code response_type} value that client supports + * @return the {@link Builder} for further configuration + */ + public Builder responseType(String responseType) { + addClaimToClaimList(OidcClientMetadataClaimNames.RESPONSE_TYPES, responseType); + return this; + } + + /** + * Add {@code Consumer} of {@code response_types} allowing the ability to add, replace, or remove + * {@link OidcClientRegistration}, OPTIONAL. + * + * @param responseType the OAuth 2.0 {@code response_type} value that client supports + * @return the {@link Builder} for further configuration + */ + public Builder responseTypes(Consumer> responseType) { + acceptClaimValues(OidcClientMetadataClaimNames.RESPONSE_TYPES, responseType); + return this; + } + + /** + * Sets {@code client_name} claim in the resulting + * {@link OidcClientRegistration}, OPTIONAL. + * + * @param clientName the OAuth 2.0 {@code client_name} of the registered client + * @return the {@link Builder} for further configuration + */ + public Builder clientName(String clientName) { + return claim(OidcClientMetadataClaimNames.CLIENT_NAME, clientName); + } + + /** + * Sets {@code client_id} claim in the resulting + * {@link OidcClientRegistration}. + * + * @param clientId the OAuth 2.0 {@code client_id} of the registered client + * @return the {@link Builder} for further configuration + */ + public Builder clientId(String clientId) { + return claim(OidcClientMetadataClaimNames.CLIENT_ID, clientId); + } + + /** + * Sets {@code client_id_issued_at} claim in the resulting + * {@link OidcClientRegistration}. + * + * @param clientIssuedAt the timestamp {@code client_id_issued_at} when the client was issued + * @return the {@link Builder} for further configuration + */ + public Builder clientIdIssuedAt(Instant clientIssuedAt) { + return claim(OidcClientMetadataClaimNames.CLIENT_ID_ISSUED_AT, clientIssuedAt); + } + + /** + * Sets {@code client_secret} claim in the resulting + * {@link OidcClientRegistration}. + * + * @param clientSecret the {@code client_secret} of the registered client + * @return the {@link Builder} for further configuration + */ + public Builder clientSecret(String clientSecret) { + return claim(OidcClientMetadataClaimNames.CLIENT_SECRET, clientSecret); + } + + /** + * Sets {@code client_secret_expires_at} claim in the resulting + * {@link OidcClientRegistration}. + * + * @param clientSecretExpiresAt the timestamp {@code client_secret_expires_at} when the client_secret expires + * @return the {@link Builder} for further configuration + */ + public Builder clientSecretExpiresAt(Instant clientSecretExpiresAt) { + return claim(OidcClientMetadataClaimNames.CLIENT_SECRET_EXPIRES_AT, clientSecretExpiresAt); + } + + /** + * Add this Grant Type to the collection of {@code grant_types_supported} in the resulting + * {@link OidcClientRegistration}, OPTIONAL. + * + * @param grantType the OAuth 2.0 {@code grant_type} value that client supports + * @return the {@link Builder} for further configuration + */ + public Builder grantType(String grantType) { + addClaimToClaimList(OidcClientMetadataClaimNames.GRANT_TYPES, 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(OidcClientMetadataClaimNames.GRANT_TYPES, grantTypesConsumer); + return this; + } + + /** + * Add this Scope to the collection of {@code scopes_supported} in the resulting + * {@link OidcClientRegistration}, RECOMMENDED. + * + * @param scope the OAuth 2.0 {@code scope} value that client supports + * @return the {@link Builder} for further configuration + */ + public Builder scope(String scope) { + claim(OidcClientMetadataClaimNames.SCOPE, scope); + return this; + } + + /** + * Add {@code Consumer} of {@code scopes} allowing the ability to add, replace, or remove + * {@link OidcClientRegistration}, RECOMMENDED. + * + * @param scopesConsumer the OAuth 2.0 {@code scope} value that client supports + * @return the {@link Builder} for further configuration + */ + public Builder scopes(Consumer> scopesConsumer) { + acceptClaimValues(OidcClientMetadataClaimNames.SCOPE, scopesConsumer); + return this; + } + + /** + * Add this Token endpoint authentication method to the collection of {@code token_endpoint_auth_method} in the resulting + * {@link OidcClientRegistration}, OPTIONAL. + * + * @param tokenEndpointAuthenticationMethod the OAuth 2.0 {@code token_endpoint_auth_method} value that client supports + * @return the {@link Builder} for further configuration + */ + public Builder tokenEndpointAuthenticationMethod(String tokenEndpointAuthenticationMethod) { + claim(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, tokenEndpointAuthenticationMethod); + return this; + } + + /** + * Add this claim in the resulting {@link OidcClientRegistration}. + * + * @param name the claim name + * @param value the claim value + * @return the {@link Builder} for further configuration + */ + public Builder 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 this; + } + + /** + * 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 Builder} for further configurations + */ + public Builder claims(Consumer> claimsConsumer) { + claimsConsumer.accept(this.claims); + return this; + } + + public OidcClientRegistration build() { + this.claims.computeIfAbsent(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, + k -> ClientAuthenticationMethod.BASIC.getValue()); + // If omitted, the default is that the Client will use only the authorization_code Grant Type. + this.claims.computeIfAbsent(OidcClientMetadataClaimNames.GRANT_TYPES, + k -> Collections.singletonList(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())); + //If omitted, the default is that the Client will use only the code Response Type. + this.claims.computeIfAbsent(OidcClientMetadataClaimNames.RESPONSE_TYPES, + k -> Collections.singletonList(OAuth2AuthorizationResponseType.CODE.getValue())); + validateRedirectUris(); + validateReponseTypesClaim(); + validateGrantTypesClaim(); + return new OidcClientRegistration(this.claims); + } + + private void validateRedirectUris() { + // redirect_uris is required + Assert.notNull(this.claims.get(OidcClientMetadataClaimNames.REDIRECT_URIS), "redirect_uris cannot be null"); + Assert.isInstanceOf(List.class, this.claims.get(OidcClientMetadataClaimNames.REDIRECT_URIS), "redirect_uris must be of type list"); + Assert.notEmpty((List) this.claims.get(OidcClientMetadataClaimNames.REDIRECT_URIS), "redirect_uris must not be empty"); + ((List) this.claims.get(OidcClientMetadataClaimNames.REDIRECT_URIS)).forEach( + url -> validateURL(url, "redirect_uri must be a valid URL") + ); + } + + private void validateGrantTypesClaim() { + Assert.isInstanceOf(List.class, this.claims.get(OidcClientMetadataClaimNames.GRANT_TYPES), "grant_types must be of type List"); + List grantTypes = (List) this.claims.get(OidcClientMetadataClaimNames.GRANT_TYPES); + // If empty, the default is that the Client will use only the authorization_code Grant Type. + if (grantTypes.isEmpty()) { + this.claims.put(OidcClientMetadataClaimNames.GRANT_TYPES, + Collections.singletonList(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())); + } + } + + private void validateReponseTypesClaim() { + Assert.isInstanceOf(List.class, this.claims.get(OidcClientMetadataClaimNames.RESPONSE_TYPES), "response_types must be of type List"); + List responseTypes = (List) this.claims.get(OidcClientMetadataClaimNames.RESPONSE_TYPES); + //If empty, the default is that the Client will use only the code Response Type. + if (responseTypes.isEmpty()) { + this.claims.put(OidcClientMetadataClaimNames.RESPONSE_TYPES, Collections.singletonList(OAuth2AuthorizationResponseType.CODE.getValue())); + } + } + + 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/http/converter/OidcClientRegistrationHttpMessageConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcClientRegistrationHttpMessageConverter.java new file mode 100644 index 000000000..5fe633e00 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcClientRegistrationHttpMessageConverter.java @@ -0,0 +1,157 @@ +/* + * Copyright 2020-2021 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.oidc.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.converter.ClaimConversionService; +import org.springframework.security.oauth2.core.converter.ClaimTypeConverter; +import org.springframework.security.oauth2.core.oidc.OidcClientMetadataClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcClientRegistration; +import org.springframework.util.Assert; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +/** + * A {@link HttpMessageConverter} for an {@link OidcClientRegistration OpenID Client Registration Response}. + * + * @author Ovidiu Popa + * @see AbstractHttpMessageConverter + * @see OidcClientRegistration + * @since 0.1.1 + */ +public class OidcClientRegistrationHttpMessageConverter extends AbstractHttpMessageConverter { + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = + new ParameterizedTypeReference>() { + }; + + private Converter, OidcClientRegistration> clientRegistrationConverter = + new OidcClientRegistrationConverter(); + + private Converter> clientRegistrationParametersConverter = OidcClientRegistration::getClaims; + private final GenericHttpMessageConverter jsonMessageConverter = HttpMessageConverters.getJsonMessageConverter(); + + public OidcClientRegistrationHttpMessageConverter() { + super(MediaType.APPLICATION_JSON, new MediaType("application", "*+json")); + } + + @Override + protected boolean supports(Class clazz) { + return OidcClientRegistration.class.isAssignableFrom(clazz); + } + + @Override + @SuppressWarnings("unchecked") + protected OidcClientRegistration readInternal(Class clazz, HttpInputMessage inputMessage) + throws HttpMessageNotReadableException { + try { + Map clientRegistrationParameters = + (Map) this.jsonMessageConverter.read(STRING_OBJECT_MAP.getType(), null, inputMessage); + return this.clientRegistrationConverter.convert(clientRegistrationParameters); + } catch (Exception ex) { + throw new HttpMessageNotReadableException( + "An error occurred reading the OpenID Client Registration Request: " + ex.getMessage(), ex, inputMessage); + } + } + + @Override + protected void writeInternal(OidcClientRegistration oidcClientRegistration, HttpOutputMessage outputMessage) + throws HttpMessageNotWritableException { + + try { + Map claims = clientRegistrationParametersConverter.convert(oidcClientRegistration); + this.jsonMessageConverter.write( + claims, + STRING_OBJECT_MAP.getType(), + MediaType.APPLICATION_JSON, + outputMessage + ); + } catch (Exception ex) { + throw new HttpMessageNotWritableException( + "An error occurred writing the OpenID Client Registration response: " + ex.getMessage(), ex); + } + + } + + /** + * Sets the {@link Converter} used for converting the OpenID Client Registration parameters + * to an {@link OidcClientRegistration}. + * + * @param clientRegistrationConverter the {@link Converter} used for converting to an + * {@link OidcClientRegistration} + */ + public void setClientRegistrationConverter(Converter, OidcClientRegistration> clientRegistrationConverter) { + Assert.notNull(clientRegistrationConverter, "clientRegistrationConverter cannot be null"); + this.clientRegistrationConverter = clientRegistrationConverter; + } + + /** + * Sets the {@link Converter} used for converting the {@link OidcClientRegistration} to a + * {@code Map} representation of the OpenID Client Registration Response. + * + * @param clientRegistrationParametersConverter the {@link Converter} used for converting to a + * {@code Map} representation of the OpenID Client Registration Response + */ + public final void setClientRegistrationParametersConverter( + Converter> clientRegistrationParametersConverter) { + Assert.notNull(clientRegistrationParametersConverter, "clientRegistrationParametersConverter cannot be null"); + this.clientRegistrationParametersConverter = clientRegistrationParametersConverter; + } + + private static final class OidcClientRegistrationConverter implements Converter, OidcClientRegistration> { + 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 final ClaimTypeConverter claimTypeConverter; + + private OidcClientRegistrationConverter() { + Converter collectionStringConverter = getConverter( + TypeDescriptor.collection(Collection.class, STRING_TYPE_DESCRIPTOR)); + Converter stringConverter = getConverter(STRING_TYPE_DESCRIPTOR); + + Map> claimConverters = new HashMap<>(); + claimConverters.put(OidcClientMetadataClaimNames.REDIRECT_URIS, collectionStringConverter); + claimConverters.put(OidcClientMetadataClaimNames.RESPONSE_TYPES, collectionStringConverter); + claimConverters.put(OidcClientMetadataClaimNames.GRANT_TYPES, collectionStringConverter); + claimConverters.put(OidcClientMetadataClaimNames.CLIENT_NAME, stringConverter); + claimConverters.put(OidcClientMetadataClaimNames.SCOPE, stringConverter); + claimConverters.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, stringConverter); + this.claimTypeConverter = new ClaimTypeConverter(claimConverters); + } + + @Override + public OidcClientRegistration convert(Map source) { + Map parsedClaims = this.claimTypeConverter.convert(source); + return OidcClientRegistration.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/server/authorization/authentication/OidcClientRegistrationAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcClientRegistrationAuthenticationProvider.java new file mode 100644 index 000000000..d73d1d02f --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcClientRegistrationAuthenticationProvider.java @@ -0,0 +1,86 @@ +/* + * Copyright 2020-2021 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.authentication; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.util.Assert; + +/** + * An {@link AuthenticationProvider} implementation for OpenID Client Registration Endpoint. + * + * @author Ovidiu Popa + * @since 0.1.1 + * @see JwtAuthenticationToken + * @see OAuth2AuthorizationService + */ +public class OidcClientRegistrationAuthenticationProvider implements AuthenticationProvider { + + private static final String CLIENT_CREATE_SCOPE = "client.create"; + private final OAuth2AuthorizationService authorizationService; + + /** + * Constructs an {@code OidcClientRegistrationAuthenticationProvider} using the provided parameters. + * + * @param authorizationService the authorization service + */ + public OidcClientRegistrationAuthenticationProvider(OAuth2AuthorizationService authorizationService) { + Assert.notNull(authorizationService, "authorizationService cannot be null"); + this.authorizationService = authorizationService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + JwtAuthenticationToken jwtAuthenticationToken = + (JwtAuthenticationToken) authentication; + + String tokenValue = jwtAuthenticationToken.getToken().getTokenValue(); + OAuth2Authorization authorization = this.authorizationService.findByToken(tokenValue, OAuth2TokenType.ACCESS_TOKEN); + + if (authorization == null) { + throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT)); + } + + OAuth2Authorization.Token authorizationAccessToken = + authorization.getAccessToken(); + if (authorizationAccessToken.isInvalidated()) { + throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT)); + } + OAuth2AccessToken accessToken = authorizationAccessToken.getToken(); + if (!accessToken.getScopes().contains(CLIENT_CREATE_SCOPE)) { + throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT)); + } + + authorization = OAuth2AuthenticationProviderUtils.invalidate(authorization, accessToken); + this.authorizationService.save(authorization); + + return jwtAuthenticationToken; + } + + @Override + public boolean supports(Class authentication) { + return JwtAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepository.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepository.java index d02c73423..4a840487b 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepository.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepository.java @@ -88,4 +88,21 @@ public RegisteredClient findByClientId(String clientId) { Assert.hasText(clientId, "clientId cannot be empty"); return this.clientIdRegistrationMap.get(clientId); } + + @Override + public void saveClient(RegisteredClient registeredClient) { + Assert.notNull(registeredClient, "registeredClient cannot be null"); + String id = registeredClient.getId(); + if (idRegistrationMap.containsKey(id)) { + throw new IllegalArgumentException("Registered client must be unique. " + + "Found duplicate identifier: " + id); + } + String clientId = registeredClient.getClientId(); + if (clientIdRegistrationMap.containsKey(clientId)) { + throw new IllegalArgumentException("Registered client must be unique. " + + "Found duplicate client identifier: " + clientId); + } + this.idRegistrationMap.put(registeredClient.getId(), registeredClient); + this.clientIdRegistrationMap.put(registeredClient.getClientId(), registeredClient); + } } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClientRepository.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClientRepository.java index 8905254c2..60182f8f1 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClientRepository.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClientRepository.java @@ -47,4 +47,11 @@ public interface RegisteredClientRepository { @Nullable RegisteredClient findByClientId(String clientId); + /** + * Saves a new registered client + * + * @param registeredClient the {@link RegisteredClient} to be saved + */ + void saveClient(RegisteredClient registeredClient); + } 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..2dbcba436 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 @@ -33,6 +33,8 @@ public class ProviderSettings extends Settings { public static final String JWK_SET_ENDPOINT = PROVIDER_SETTING_BASE.concat("jwk-set-endpoint"); public static final String TOKEN_REVOCATION_ENDPOINT = PROVIDER_SETTING_BASE.concat("token-revocation-endpoint"); public static final String TOKEN_INTROSPECTION_ENDPOINT = PROVIDER_SETTING_BASE.concat("token-introspection-endpoint"); + public static final String OIDC_CLIENT_REGISTRATION_ENDPOINT = PROVIDER_SETTING_BASE.concat("oidc-client-registration-endpoint"); + public static final String ENABLE_OIDC_CLIENT_REGISTRATION_ENDPOINT = PROVIDER_SETTING_BASE.concat("enable-oidc-client-registration-endpoint"); /** * Constructs a {@code ProviderSettings}. @@ -164,6 +166,46 @@ public ProviderSettings tokenIntrospectionEndpoint(String tokenIntrospectionEndp return setting(TOKEN_INTROSPECTION_ENDPOINT, tokenIntrospectionEndpoint); } + /** + * Returns the Provider's OAuth 2.0 OIDC Client Registration endpoint. The default is {@code /connect/register}. + * + * @return the OIDC Client Registration endpoint + */ + public String oidcClientRegistrationEndpoint() { + return setting(OIDC_CLIENT_REGISTRATION_ENDPOINT); + } + + /** + * Sets the Provider's OAuth 2.0 OIDC Client Registration endpoint. + * + * @param oidcClientRegistrationEndpoint the Token Revocation endpoint + * @return the {@link ProviderSettings} for further configuration + */ + public ProviderSettings oidcClientRegistrationEndpoint(String oidcClientRegistrationEndpoint) { + return setting(OIDC_CLIENT_REGISTRATION_ENDPOINT, oidcClientRegistrationEndpoint); + } + + /** + * Returns {@code true} if the OIDC Client Registration endpoint is enabled. + * The default is {@code false}. + * + * @return {@code true} if the OIDC Client Registration endpoint is enabled, {@code false} otherwise + */ + public boolean isOidClientRegistrationEndpointEnabled() { + return setting(ENABLE_OIDC_CLIENT_REGISTRATION_ENDPOINT); + } + + /** + * Set to {@code true} if the OIDC Client Registration Endpoint should be enabled. + * + * @param oidClientRegistrationEndpointEnabled {@code true} if the OIDC Client Registration endpoint should enabled + * @return the {@link ProviderSettings} + */ + public ProviderSettings isOidClientRegistrationEndpointEnabled(boolean oidClientRegistrationEndpointEnabled) { + setting(ENABLE_OIDC_CLIENT_REGISTRATION_ENDPOINT, oidClientRegistrationEndpointEnabled); + return this; + } + protected static Map defaultSettings() { Map settings = new HashMap<>(); settings.put(AUTHORIZATION_ENDPOINT, "/oauth2/authorize"); @@ -171,6 +213,8 @@ protected static Map defaultSettings() { settings.put(JWK_SET_ENDPOINT, "/oauth2/jwks"); settings.put(TOKEN_REVOCATION_ENDPOINT, "/oauth2/revoke"); settings.put(TOKEN_INTROSPECTION_ENDPOINT, "/oauth2/introspect"); + settings.put(OIDC_CLIENT_REGISTRATION_ENDPOINT, "/connect/register"); + settings.put(ENABLE_OIDC_CLIENT_REGISTRATION_ENDPOINT, false); return settings; } } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilter.java new file mode 100644 index 000000000..40d130716 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilter.java @@ -0,0 +1,194 @@ +/* + * Copyright 2020-2021 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.oidc.web; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType; +import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; +import org.springframework.security.oauth2.core.oidc.OidcClientRegistration; +import org.springframework.security.oauth2.core.oidc.http.converter.OidcClientRegistrationHttpMessageConverter; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +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 javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * A {@code Filter} that processes OpenID Client Registration Requests. + * @author Ovidiu Popa + * @since 0.1.1 + * @see OidcClientRegistration + * @see 3.1. Client Registration Request + */ +public class OidcClientRegistrationEndpointFilter extends OncePerRequestFilter { + /** + * The default endpoint {@code URI} for OpenID Client Registration requests. + */ + public static final String DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI = "/connect/register"; + private static final String SCOPE_CLAIM_DELIMITER = " "; + + private final OidcClientRegistrationHttpMessageConverter clientRegistrationHttpMessageConverter = + new OidcClientRegistrationHttpMessageConverter(); + private final RegisteredClientRepository registeredClientRepository; + private final OidcClientRegistrationToRegisteredClientConverter oidcClientToRegisteredClientConverter = + new OidcClientRegistrationToRegisteredClientConverter(); + private final RegisteredClientToOidcClientRegistrationConverter registeredClientToOidcClientConverter = + new RegisteredClientToOidcClientRegistrationConverter(); + private final HttpMessageConverter errorHttpResponseConverter = + new OAuth2ErrorHttpMessageConverter(); + private final RequestMatcher requestMatcher; + private final AuthenticationManager authenticationManager; + + /** + * Constructs an {@code OidcClientRegistrationEndpointFilter} using the provided parameters. + * + * @param registeredClientRepository the repository of registered clients + * @param authenticationManager the authentication manager + */ + public OidcClientRegistrationEndpointFilter(RegisteredClientRepository registeredClientRepository, + AuthenticationManager authenticationManager) { + this(registeredClientRepository, authenticationManager, DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI); + } + + /** + * Constructs an {@code OidcClientRegistrationEndpointFilter} using the provided parameters. + * + * @param registeredClientRepository the repository of registered clients + * @param authenticationManager the authentication manager + * @param oidcClientRegistrationUri the endpoint {@code URI} for OIDC Client Registration requests + */ + public OidcClientRegistrationEndpointFilter(RegisteredClientRepository registeredClientRepository, + AuthenticationManager authenticationManager, String oidcClientRegistrationUri) { + Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null"); + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + Assert.hasText(oidcClientRegistrationUri, "oidcClientRegistrationUri cannot be empty"); + this.registeredClientRepository = registeredClientRepository; + this.authenticationManager = authenticationManager; + this.requestMatcher = new AntPathRequestMatcher( + oidcClientRegistrationUri, + HttpMethod.POST.name() + ); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + if (!this.requestMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + + try { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + authenticationManager.authenticate(authentication); + OidcClientRegistration clientRegistrationRequest = + this.clientRegistrationHttpMessageConverter.read(OidcClientRegistration.class, new ServletServerHttpRequest(request)); + + RegisteredClient registeredClient = this.oidcClientToRegisteredClientConverter + .convert(clientRegistrationRequest); + this.registeredClientRepository.saveClient(registeredClient); + + OidcClientRegistration convert = this.registeredClientToOidcClientConverter + .convert(registeredClient); + + final ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response); + httpResponse.setStatusCode(HttpStatus.CREATED); + this.clientRegistrationHttpMessageConverter.write( + convert, MediaType.APPLICATION_JSON, httpResponse); + } catch (OAuth2AuthenticationException ex) { + SecurityContextHolder.clearContext(); + sendErrorResponse(response, ex.getError()); + } + } + + private void sendErrorResponse(HttpServletResponse response, OAuth2Error error) throws IOException { + ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response); + httpResponse.setStatusCode(HttpStatus.BAD_REQUEST); + this.errorHttpResponseConverter.write(error, null, httpResponse); + } + + private static class OidcClientRegistrationToRegisteredClientConverter implements Converter { + + @Override + public RegisteredClient convert(OidcClientRegistration clientRegistration) { + return RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId(UUID.randomUUID().toString()) + .clientSecret(UUID.randomUUID().toString()) + .redirectUris(redirectUris -> + redirectUris.addAll(clientRegistration.getRedirectUris())) + .clientAuthenticationMethod(new ClientAuthenticationMethod(clientRegistration.getTokenEndpointAuthenticationMethod())) + .authorizationGrantTypes(grantTypes -> + grantTypes.addAll(this.grantTypes(clientRegistration))) + .scopes(scopes -> + scopes.addAll(Arrays.asList(clientRegistration.getScope().split(SCOPE_CLAIM_DELIMITER)))) + .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) + .build(); + } + + private List grantTypes(OidcClientRegistration clientRegistration) { + return clientRegistration.getGrantTypes().stream() + .map(AuthorizationGrantType::new) + .collect(Collectors.toList()); + } + } + + private static class RegisteredClientToOidcClientRegistrationConverter implements Converter { + + @Override + public OidcClientRegistration convert(RegisteredClient source) { + return OidcClientRegistration.builder() + .clientId(source.getClientId()) + .redirectUris(uris -> uris.addAll(source.getRedirectUris())) + .clientIdIssuedAt(Instant.now()) + .clientSecret(source.getClientSecret()) + .clientSecretExpiresAt(Instant.EPOCH) + .responseType(OAuth2AuthorizationResponseType.CODE.getValue()) + .grantTypes(grantTypes -> + grantTypes.addAll(source.getAuthorizationGrantTypes().stream().map(AuthorizationGrantType::getValue) + .collect(Collectors.toList())) + ) + .scope(String.join(SCOPE_CLAIM_DELIMITER, source.getScopes())) + .tokenEndpointAuthenticationMethod(source.getClientAuthenticationMethods().iterator().next().getValue()) + .build(); + } + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/JwkSetTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/JwkSetTests.java index 49b887c52..6d3d66a3d 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/JwkSetTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/JwkSetTests.java @@ -22,7 +22,6 @@ 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; diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2ClientCredentialsGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2ClientCredentialsGrantTests.java index 3188af844..6324a20ae 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2ClientCredentialsGrantTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2ClientCredentialsGrantTests.java @@ -15,10 +15,6 @@ */ package org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.Base64; - import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; @@ -26,7 +22,6 @@ 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; @@ -37,16 +32,20 @@ import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.jose.TestJwks; +import org.springframework.security.oauth2.server.authorization.JwtEncodingContext; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenCustomizer; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; -import org.springframework.security.oauth2.server.authorization.JwtEncodingContext; -import org.springframework.security.oauth2.server.authorization.OAuth2TokenCustomizer; import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2RefreshTokenGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2RefreshTokenGrantTests.java index 5c13eade7..fd8bde818 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2RefreshTokenGrantTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2RefreshTokenGrantTests.java @@ -15,14 +15,6 @@ */ package org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.security.Principal; -import java.util.Base64; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; @@ -30,7 +22,6 @@ 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; @@ -45,6 +36,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2TokenType; import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; @@ -52,21 +44,28 @@ import org.springframework.security.oauth2.jose.TestKeys; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.authorization.JwtEncodingContext; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenCustomizer; import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations; -import org.springframework.security.oauth2.core.OAuth2TokenType; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; -import org.springframework.security.oauth2.server.authorization.JwtEncodingContext; -import org.springframework.security.oauth2.server.authorization.OAuth2TokenCustomizer; import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.util.Base64; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.CoreMatchers.containsString; import static org.mockito.ArgumentMatchers.any; diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2TokenRevocationTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2TokenRevocationTests.java index d933e9eb4..7ede265aa 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2TokenRevocationTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2TokenRevocationTests.java @@ -15,10 +15,6 @@ */ package org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.Base64; - import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; @@ -27,7 +23,6 @@ import org.junit.Rule; import org.junit.Test; import org.mockito.ArgumentCaptor; - import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; @@ -38,12 +33,12 @@ import org.springframework.security.oauth2.core.AbstractOAuth2Token; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.OAuth2TokenType; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames2; import org.springframework.security.oauth2.jose.TestJwks; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations; -import org.springframework.security.oauth2.core.OAuth2TokenType; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; @@ -53,6 +48,10 @@ import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcClientRegistrationTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcClientRegistrationTests.java new file mode 100644 index 000000000..b11277996 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcClientRegistrationTests.java @@ -0,0 +1,241 @@ +/* + * Copyright 2020-2021 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.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.mock.web.MockHttpServletResponse; +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.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2TokenType; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; +import org.springframework.security.oauth2.core.oidc.OidcClientRegistration; +import org.springframework.security.oauth2.core.oidc.http.converter.OidcClientRegistrationHttpMessageConverter; +import org.springframework.security.oauth2.jose.TestJwks; +import org.springframework.security.oauth2.jose.TestKeys; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; +import org.springframework.security.oauth2.server.authorization.config.ProviderSettings; +import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests for OpenID Connect 1.0 Client Registration Endpoint. + * + * @author Ovidiu Popa + * @since 0.1.1 + */ +public class OidcClientRegistrationTests { + private static final OidcClientRegistration.Builder OIDC_CLIENT_REGISTRATION = OidcClientRegistration.builder() + .redirectUri("https://localhost:8080/client") + .responseType(OAuth2AuthorizationResponseType.CODE.getValue()) + .grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) + .tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.BASIC.getValue()) + .scope("test"); + + private static final HttpMessageConverter accessTokenHttpResponseConverter = + new OAuth2AccessTokenResponseHttpMessageConverter(); + + private static final OidcClientRegistrationHttpMessageConverter clientRegistrationHttpMessageConverter = + new OidcClientRegistrationHttpMessageConverter(); + + private static final OAuth2TokenType ACCESS_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.ACCESS_TOKEN); + + private static RegisteredClientRepository registeredClientRepository; + private static OAuth2AuthorizationService authorizationService; + private static JWKSource jwkSource; + private static NimbusJwtDecoder jwtDecoder; + + @Rule + public final SpringTestRule spring = new SpringTestRule(); + + @Autowired + private MockMvc mvc; + + @BeforeClass + public static void init() { + registeredClientRepository = mock(RegisteredClientRepository.class); + authorizationService = mock(OAuth2AuthorizationService.class); + JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK); + jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); + jwtDecoder = NimbusJwtDecoder.withPublicKey(TestKeys.DEFAULT_PUBLIC_KEY).build(); + } + + @Before + public void setup() { + reset(registeredClientRepository); + reset(authorizationService); + } + + @Test + public void requestWhenAuthenticatedThenResponseIncludesRegisteredClientDetails() throws Exception { + this.spring.register(AuthorizationServerConfigurationEnabledClientRegistration.class).autowire(); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient2() + .scope("client.create").build(); + when(registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + // get access token + MvcResult mvcResult = this.mvc.perform(post(OAuth2TokenEndpointFilter.DEFAULT_TOKEN_ENDPOINT_URI) + .param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .param(OAuth2ParameterNames.SCOPE, "client.create") + .header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth( + registeredClient.getClientId(), registeredClient.getClientSecret()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andExpect(jsonPath("$.scope").value("client.create")) + .andReturn(); + + //assert get access token + verify(registeredClientRepository).findByClientId(eq(registeredClient.getClientId())); + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(authorizationService).save(authorizationCaptor.capture()); + OAuth2Authorization authorization = authorizationCaptor.getValue(); + MockHttpServletResponse servletResponse = mvcResult.getResponse(); + MockClientHttpResponse httpResponse = new MockClientHttpResponse( + servletResponse.getContentAsByteArray(), HttpStatus.valueOf(servletResponse.getStatus())); + OAuth2AccessTokenResponse accessTokenResponse = accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse); + String tokenValue = accessTokenResponse.getAccessToken().getTokenValue(); + + // prepare register client request + when(authorizationService.findByToken( + eq(authorization.getToken(OAuth2AccessToken.class).getToken().getTokenValue()), + eq(ACCESS_TOKEN_TOKEN_TYPE))) + .thenReturn(authorization); + doNothing().when(registeredClientRepository).saveClient(any(RegisteredClient.class)); + mvcResult = this.mvc.perform(post("/connect/register") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + tokenValue) + .contentType(MediaType.APPLICATION_JSON) + .content(convertToByteArray(OIDC_CLIENT_REGISTRATION.build()))) + .andExpect(status().isCreated()).andReturn(); + + servletResponse = mvcResult.getResponse(); + httpResponse = new MockClientHttpResponse( + servletResponse.getContentAsByteArray(), HttpStatus.valueOf(servletResponse.getStatus())); + + OidcClientRegistration result = clientRegistrationHttpMessageConverter.read(OidcClientRegistration.class, httpResponse); + + + assertThat(result).isNotNull(); + assertThat(result.getClaimAsString("client_id")).isNotEmpty(); + assertThat(result.getClaimAsString("client_id_issued_at")).isNotEmpty(); + assertThat(result.getClaimAsString("client_secret")).isNotEmpty(); + assertThat(result.getClaimAsString("client_secret_expires_at")).isNotNull().isEqualTo("0.0"); + assertThat(result.getRedirectUris()).isNotEmpty().containsExactly("https://localhost:8080/client"); + assertThat(result.getResponseTypes()).isNotEmpty().containsExactly(OAuth2AuthorizationResponseType.CODE.getValue()); + assertThat(result.getGrantTypes()).isNotEmpty().containsExactly(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()); + assertThat(result.getTokenEndpointAuthenticationMethod()).isNotEmpty().isEqualTo(ClientAuthenticationMethod.BASIC.getValue()); + assertThat(result.getScope()).isNotEmpty().isEqualTo("test"); + } + + private static String encodeBasicAuth(String clientId, String secret) throws Exception { + clientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8.name()); + secret = URLEncoder.encode(secret, StandardCharsets.UTF_8.name()); + String credentialsString = clientId + ":" + secret; + byte[] encodedBytes = Base64.getEncoder().encode(credentialsString.getBytes(StandardCharsets.UTF_8)); + return new String(encodedBytes, StandardCharsets.UTF_8); + } + + private static byte[] convertToByteArray(OidcClientRegistration clientRegistration) throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + + return objectMapper + .writerFor(Map.class) + .writeValueAsBytes(clientRegistration.getClaims()); + } + + @EnableWebSecurity + @Import(OAuth2AuthorizationServerConfiguration.class) + static class AuthorizationServerConfiguration { + + @Bean + RegisteredClientRepository registeredClientRepository() { + return registeredClientRepository; + } + + @Bean + OAuth2AuthorizationService authorizationService() { + return authorizationService; + } + + @Bean + JWKSource jwkSource() { + return jwkSource; + } + + + } + + @EnableWebSecurity + @Import(OAuth2AuthorizationServerConfiguration.class) + static class AuthorizationServerConfigurationEnabledClientRegistration extends AuthorizationServerConfiguration{ + + @Bean + JwtDecoder jwtDecoder() { + return jwtDecoder; + } + + @Bean + ProviderSettings providerSettings() { + return new ProviderSettings().isOidClientRegistrationEndpointEnabled(true); + } + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcTests.java index b895d5c20..14aae367a 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OidcTests.java @@ -56,6 +56,7 @@ import org.springframework.security.oauth2.jose.TestJwks; import org.springframework.security.oauth2.jose.TestKeys; import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.authorization.JwtEncodingContext; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; @@ -273,6 +274,11 @@ OAuth2TokenCustomizer jwtCustomizer() { } }; } + + @Bean + JwtDecoder jwtDecoder(){ + return jwtDecoder; + } } @EnableWebSecurity diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/OidcClientRegistrationTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/OidcClientRegistrationTests.java new file mode 100644 index 000000000..d6996d04f --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/OidcClientRegistrationTests.java @@ -0,0 +1,331 @@ +/* + * Copyright 2020-2021 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.oidc; + +import org.junit.Test; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType; + +import java.net.URL; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link OidcClientRegistration} + * + * @author Ovidiu Popa + * @since 0.1.1 + */ +public class OidcClientRegistrationTests { + + private final OidcClientRegistration.Builder clientRegistrationBuilder = + OidcClientRegistration.builder(); + + @Test + public void buildWhenAllRequiredClaimsAndAdditionalClaimsThenCreated() { + OidcClientRegistration clientRegistration = OidcClientRegistration.builder() + .redirectUri("http://client.example.com") + .grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) + .grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .responseType(OAuth2AuthorizationResponseType.CODE.getValue()) + .scope("test read") + .tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.BASIC.getValue()) + .build(); + + assertThat(clientRegistration.getRedirectUris()) + .containsOnly("http://client.example.com"); + assertThat(clientRegistration.getGrantTypes()) + .contains( + AuthorizationGrantType.AUTHORIZATION_CODE.getValue(), + AuthorizationGrantType.CLIENT_CREDENTIALS.getValue() + ); + assertThat(clientRegistration.getResponseTypes()) + .contains(OAuth2AuthorizationResponseType.CODE.getValue()); + assertThat(clientRegistration.getScope()) + .isEqualTo("test read"); + assertThat(clientRegistration.getTokenEndpointAuthenticationMethod()) + .isEqualTo(ClientAuthenticationMethod.BASIC.getValue()); + + } + + @Test + public void buildWhenAllRequiredClaimsThenCreated() { + OidcClientRegistration clientRegistration = OidcClientRegistration.builder() + .redirectUri("http://client.example.com") + .build(); + + assertThat(clientRegistration.getRedirectUris()) + .containsOnly("http://client.example.com"); + assertThat(clientRegistration.getGrantTypes()) + .containsOnly(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()); + assertThat(clientRegistration.getResponseTypes()) + .containsOnly(OAuth2AuthorizationResponseType.CODE.getValue()); + assertThat(clientRegistration.getScope()) + .isNull(); + assertThat(clientRegistration.getTokenEndpointAuthenticationMethod()) + .isEqualTo(ClientAuthenticationMethod.BASIC.getValue()); + } + + @Test + public void buildWhenAllRequiredClaimsAndAuthorizationGrantTypeButMissingResponseTypeThenCreated() { + OidcClientRegistration clientRegistration = OidcClientRegistration.builder() + .redirectUri("http://client.example.com") + .grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) + .build(); + + assertThat(clientRegistration.getRedirectUris()) + .containsOnly("http://client.example.com"); + assertThat(clientRegistration.getGrantTypes()) + .containsOnly(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()); + assertThat(clientRegistration.getResponseTypes()) + .containsOnly(OAuth2AuthorizationResponseType.CODE.getValue()); + } + + @Test + public void buildWhenAllRequiredClaimsAndEmptyGrantTypeListButMissingResponseTypeThenCreated() { + OidcClientRegistration clientRegistration = OidcClientRegistration.builder() + .redirectUri("http://client.example.com") + .grantTypes(List::clear) + .build(); + + assertThat(clientRegistration.getRedirectUris()) + .containsOnly("http://client.example.com"); + assertThat(clientRegistration.getGrantTypes()) + .containsOnly(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()); + assertThat(clientRegistration.getResponseTypes()) + .containsOnly(OAuth2AuthorizationResponseType.CODE.getValue()); + } + + @Test + public void buildWhenAllRequiredClaimsAndResponseTypeButMissingAuthorizationGrantTypeThenCreated() { + OidcClientRegistration clientRegistration = OidcClientRegistration.builder() + .redirectUri("http://client.example.com") + .responseType(OAuth2AuthorizationResponseType.CODE.getValue()) + .build(); + + assertThat(clientRegistration.getRedirectUris()) + .containsOnly("http://client.example.com"); + assertThat(clientRegistration.getGrantTypes()) + .containsOnly(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()); + assertThat(clientRegistration.getResponseTypes()) + .containsOnly(OAuth2AuthorizationResponseType.CODE.getValue()); + } + + @Test + public void buildWhenAllRequiredClaimsAndEmptyResponseTypeListButMissingAuthorizationGrantTypeThenCreated() { + OidcClientRegistration clientRegistration = OidcClientRegistration.builder() + .redirectUri("http://client.example.com") + .responseTypes(List::clear) + .build(); + + assertThat(clientRegistration.getRedirectUris()) + .containsOnly("http://client.example.com"); + assertThat(clientRegistration.getGrantTypes()) + .containsOnly(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()); + assertThat(clientRegistration.getResponseTypes()) + .containsOnly(OAuth2AuthorizationResponseType.CODE.getValue()); + } + + @Test + public void buildWhenAllRequiredClaimsAndEmptyScopeThenCreated() { + OidcClientRegistration clientRegistration = OidcClientRegistration.builder() + .redirectUri("http://client.example.com") + .build(); + + assertThat(clientRegistration.getRedirectUris()) + .containsOnly("http://client.example.com"); + assertThat(clientRegistration.getScope()) + .isNull(); + } + + @Test + public void buildWhenAllRequiredClaimsAndEmptyTokenEndpointAuthMethodThenCreated() { + OidcClientRegistration clientRegistration = OidcClientRegistration.builder() + .redirectUri("http://client.example.com") + .build(); + + assertThat(clientRegistration.getRedirectUris()) + .containsOnly("http://client.example.com"); + assertThat(clientRegistration.getTokenEndpointAuthenticationMethod()) + .isEqualTo(ClientAuthenticationMethod.BASIC.getValue()); + } + + @Test + public void buildWhenClaimsProvidedThenCreated() { + Map claims = new HashMap<>(); + claims.put(OidcClientMetadataClaimNames.REDIRECT_URIS, Collections.singletonList("http://client.example.com")); + claims.put(OidcClientMetadataClaimNames.GRANT_TYPES, Arrays.asList( + AuthorizationGrantType.AUTHORIZATION_CODE.getValue(), + AuthorizationGrantType.CLIENT_CREDENTIALS.getValue() + )); + claims.put(OidcClientMetadataClaimNames.RESPONSE_TYPES, + Collections.singletonList(OAuth2AuthorizationResponseType.CODE.getValue())); + claims.put(OidcClientMetadataClaimNames.SCOPE, "test read"); + claims.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, ClientAuthenticationMethod.BASIC.getValue()); + + OidcClientRegistration clientRegistration = OidcClientRegistration.withClaims(claims).build(); + + assertThat(clientRegistration.getRedirectUris()) + .containsOnly("http://client.example.com"); + assertThat(clientRegistration.getGrantTypes()) + .contains( + AuthorizationGrantType.AUTHORIZATION_CODE.getValue(), + AuthorizationGrantType.CLIENT_CREDENTIALS.getValue() + ); + assertThat(clientRegistration.getResponseTypes()) + .contains(OAuth2AuthorizationResponseType.CODE.getValue()); + assertThat(clientRegistration.getScope()) + .isEqualTo("test read"); + assertThat(clientRegistration.getTokenEndpointAuthenticationMethod()) + .isEqualTo(ClientAuthenticationMethod.BASIC.getValue()); + } + + @Test + public void buildWhenRedirectUriProvidedWithUrlThenCreated() { + Map claims = new HashMap<>(); + claims.put(OidcClientMetadataClaimNames.REDIRECT_URIS, Arrays.asList( + url("http://client.example.com"), + url("http://client.example.com/authorized") + ) + ); + claims.put(OidcClientMetadataClaimNames.GRANT_TYPES, Arrays.asList( + AuthorizationGrantType.AUTHORIZATION_CODE.getValue(), + AuthorizationGrantType.CLIENT_CREDENTIALS.getValue() + )); + claims.put(OidcClientMetadataClaimNames.RESPONSE_TYPES, + Collections.singletonList(OAuth2AuthorizationResponseType.CODE.getValue())); + claims.put(OidcClientMetadataClaimNames.SCOPE, "test read"); + claims.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, ClientAuthenticationMethod.BASIC.getValue()); + + OidcClientRegistration clientRegistration = OidcClientRegistration.withClaims(claims).build(); + + assertThat(clientRegistration.getRedirectUris()) + .contains("http://client.example.com", "http://client.example.com/authorized"); + assertThat(clientRegistration.getGrantTypes()) + .contains( + AuthorizationGrantType.AUTHORIZATION_CODE.getValue(), + AuthorizationGrantType.CLIENT_CREDENTIALS.getValue() + ); + assertThat(clientRegistration.getResponseTypes()) + .contains(OAuth2AuthorizationResponseType.CODE.getValue()); + assertThat(clientRegistration.getScope()) + .isEqualTo("test read"); + assertThat(clientRegistration.getTokenEndpointAuthenticationMethod()) + .isEqualTo(ClientAuthenticationMethod.BASIC.getValue()); + } + + @Test + public void withClaimsNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> OidcClientRegistration.withClaims(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void withClaimsEmptyThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> OidcClientRegistration.withClaims(Collections.emptyMap())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("claims cannot be empty"); + } + + @Test + public void buildWhenNullRedirectUriThenThrowIllegalArgumentException() { + OidcClientRegistration.Builder builder = this.clientRegistrationBuilder + .redirectUris((claims) -> claims.remove(OidcClientMetadataClaimNames.REDIRECT_URIS)); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("redirect_uris must not be empty"); + } + + @Test + public void buildWhenNullRedirectUriClaimThenThrowIllegalArgumentException() { + Map claims = new HashMap<>(); + claims.put(OidcClientMetadataClaimNames.REDIRECT_URIS, null); + OidcClientRegistration.Builder builder = OidcClientRegistration.withClaims(claims); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("redirect_uris cannot be null"); + } + + @Test + public void buildWhenEmptyRedirectUriListThenThrowIllegalArgumentException() { + OidcClientRegistration.Builder builder = this.clientRegistrationBuilder + .redirectUris(List::clear); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("redirect_uris must not be empty"); + } + + @Test + public void buildWhenRedirectUriNotOfTypeListThenThrowIllegalArgumentException() { + OidcClientRegistration.Builder builder = this.clientRegistrationBuilder + .claims(claims -> claims.put(OidcClientMetadataClaimNames.REDIRECT_URIS, "http://client.example.com")); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("redirect_uris must be of type list"); + } + + @Test + public void buildWhenRedirectUriNotUrlThenThrowIllegalArgumentException() { + OidcClientRegistration.Builder builder = this.clientRegistrationBuilder + .redirectUri("not url"); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("redirect_uri must be a valid URL"); + } + + @Test + public void buildWhenResponseTypesNotOfTypeListThenThrowIllegalArgumentException() { + OidcClientRegistration.Builder builder = this.clientRegistrationBuilder + .redirectUri("http://client.example.com") + .claims(claims -> claims.put(OidcClientMetadataClaimNames.RESPONSE_TYPES, OAuth2AuthorizationResponseType.CODE.getValue())); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("response_types must be of type List"); + } + + @Test + public void buildWhenGrantTypesNotOfTypeListThenThrowIllegalArgumentException() { + OidcClientRegistration.Builder builder = this.clientRegistrationBuilder + .redirectUri("http://client.example.com") + .claims(claims -> claims.put(OidcClientMetadataClaimNames.GRANT_TYPES, AuthorizationGrantType.AUTHORIZATION_CODE.getValue())); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("grant_types must be of type List"); + } + + 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/oidc/http/converter/OidcClientRegistrationHttpMessageConverterTest.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcClientRegistrationHttpMessageConverterTest.java new file mode 100644 index 000000000..de15fa3a8 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcClientRegistrationHttpMessageConverterTest.java @@ -0,0 +1,197 @@ +/* + * Copyright 2020-2021 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.oidc.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.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType; +import org.springframework.security.oauth2.core.oidc.OidcClientRegistration; + +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; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * @author Ovidiu Popa + * @since 0.1.1 + */ +public class OidcClientRegistrationHttpMessageConverterTest { + private final OidcClientRegistrationHttpMessageConverter messageConverter = + new OidcClientRegistrationHttpMessageConverter(); + + @Test + public void supportsWhenOidcClientRegistrationThenTrue() { + assertThat(this.messageConverter.supports(OidcClientRegistration.class)).isTrue(); + } + + @Test + public void setClientRegistrationReadConverterWhenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.messageConverter.setClientRegistrationConverter(null)) + .withMessageContaining("clientRegistrationConverter cannot be null"); + } + + @Test + public void setClientRegistrationWriteConverterWhenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.messageConverter.setClientRegistrationParametersConverter(null)) + .withMessageContaining("clientRegistrationParametersConverter cannot be null"); + } + + @Test + public void readInternalWhenRequiredParametersThenSuccess() { + // @formatter:off + String clientRegistrationResponse = "{\n" + + " \"redirect_uris\": [\n" + + " \"https://client.example.org/callback\"\n" + + " ]\n" + + "}\n"; + // @formatter:on + + MockClientHttpResponse response = new MockClientHttpResponse(clientRegistrationResponse.getBytes(), HttpStatus.OK); + OidcClientRegistration clientRegistration = this.messageConverter + .readInternal(OidcClientRegistration.class, response); + + assertThat(clientRegistration.getRedirectUris()) + .containsOnly("https://client.example.org/callback"); + assertThat(clientRegistration.getGrantTypes()) + .containsOnly( + AuthorizationGrantType.AUTHORIZATION_CODE.getValue() + ); + assertThat(clientRegistration.getResponseTypes()) + .contains(OAuth2AuthorizationResponseType.CODE.getValue()); + assertThat(clientRegistration.getScope()) + .isNull(); + assertThat(clientRegistration.getTokenEndpointAuthenticationMethod()) + .isEqualTo(ClientAuthenticationMethod.BASIC.getValue()); + } + + @Test + public void readInternalWhenValidParametersThenSuccess() { + // @formatter:off + String clientRegistrationResponse = "{\n" + +" \"redirect_uris\": [\n" + + " \"https://client.example.org/callback\"\n" + + " ],\n" + +" \"grant_types\": [\n" + +" \"client_credentials\",\n" + +" \"authorization_code\"\n" + +" ],\n" + +" \"response_types\":[\n" + +" \"code\"\n" + +" ],\n" + +" \"client_name\": \"My Example\",\n" + +" \"scope\": \"read write\",\n" + +" \"token_endpoint_auth_method\": \"basic\"\n" + +"}\n"; + // @formatter:on + MockClientHttpResponse response = new MockClientHttpResponse(clientRegistrationResponse.getBytes(), HttpStatus.OK); + + OidcClientRegistration clientRegistration = this.messageConverter + .readInternal(OidcClientRegistration.class, response); + assertThat(clientRegistration.getRedirectUris()) + .containsOnly("https://client.example.org/callback"); + assertThat(clientRegistration.getGrantTypes()) + .contains( + AuthorizationGrantType.AUTHORIZATION_CODE.getValue(), + AuthorizationGrantType.CLIENT_CREDENTIALS.getValue() + ); + assertThat(clientRegistration.getResponseTypes()) + .contains(OAuth2AuthorizationResponseType.CODE.getValue()); + assertThat(clientRegistration.getScope()) + .isEqualTo("read write"); + assertThat(clientRegistration.getTokenEndpointAuthenticationMethod()) + .isEqualTo(ClientAuthenticationMethod.BASIC.getValue()); + } + + @Test + public void readInternalWhenFailingConverterThenThrowException() { + String errorMessage = "this is not a valid converter"; + this.messageConverter.setClientRegistrationConverter(source -> { + throw new RuntimeException(errorMessage); + }); + MockClientHttpResponse response = new MockClientHttpResponse("{}".getBytes(), HttpStatus.OK); + + assertThatExceptionOfType(HttpMessageNotReadableException.class) + .isThrownBy(() -> this.messageConverter.readInternal(OidcClientRegistration.class, response)) + .withMessageContaining("An error occurred reading the OpenID Client Registration Request") + .withMessageContaining(errorMessage); + } + + @Test + public void readInternalWhenInvalidClientRegistrationThenThrowException() { + String clientRegistrationResponse = "{ \"redirect_uris\": null }"; + MockClientHttpResponse response = new MockClientHttpResponse(clientRegistrationResponse.getBytes(), HttpStatus.OK); + + assertThatExceptionOfType(HttpMessageNotReadableException.class) + .isThrownBy(() -> this.messageConverter.readInternal(OidcClientRegistration.class, response)) + .withMessageContaining("An error occurred reading the OpenID Client Registration Request") + .withMessageContaining("redirect_uris cannot be null"); + } + + @Test + public void writeInternalWhenClientRegistrationThenSuccess() { + OidcClientRegistration clientRegistration = OidcClientRegistration.builder() + .redirectUri("http://client.example.com/callback") + .grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) + .grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .responseType(OAuth2AuthorizationResponseType.CODE.getValue()) + .scope("test read") + .tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.BASIC.getValue()) + .build(); + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + + this.messageConverter.writeInternal(clientRegistration, outputMessage); + String clientRegistrationResponse = outputMessage.getBodyAsString(); + assertThat(clientRegistrationResponse).contains("\"redirect_uris\":[\"http://client.example.com/callback\"]"); + assertThat(clientRegistrationResponse).contains("\"grant_types\":[\"authorization_code\",\"client_credentials\"]"); + assertThat(clientRegistrationResponse).contains("\"response_types\":[\"code\"]"); + assertThat(clientRegistrationResponse).contains("\"scope\":\"test read\""); + assertThat(clientRegistrationResponse).contains("\"token_endpoint_auth_method\":\"basic\""); + } + + @Test + public void writeInternalWhenWriteFailsThenThrowsException() { + String errorMessage = "this is not a valid converter"; + Converter> failingConverter = + source -> { + throw new RuntimeException(errorMessage); + }; + this.messageConverter.setClientRegistrationParametersConverter(failingConverter); + + OidcClientRegistration clientRegistration = + OidcClientRegistration.builder() + .redirectUri("http://client.example.com") + .build(); + + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + + assertThatThrownBy(() -> this.messageConverter.writeInternal(clientRegistration, outputMessage)) + .isInstanceOf(HttpMessageNotWritableException.class) + .hasMessageContaining("An error occurred writing the OpenID Client Registration response") + .hasMessageContaining(errorMessage); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OidcClientRegistrationAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OidcClientRegistrationAuthenticationProviderTests.java new file mode 100644 index 000000000..dab9eb1dc --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OidcClientRegistrationAuthenticationProviderTests.java @@ -0,0 +1,173 @@ +/* + * Copyright 2020-2021 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.authentication; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2TokenType; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Ovidiu Popa + * @since 0.1.1 + */ +public class OidcClientRegistrationAuthenticationProviderTests { + + private OAuth2AuthorizationService authorizationService; + private OidcClientRegistrationAuthenticationProvider authenticationProvider; + + @Before + public void setUp() { + this.authorizationService = mock(OAuth2AuthorizationService.class); + this.authenticationProvider = new OidcClientRegistrationAuthenticationProvider(this.authorizationService); + } + + @Test + public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OidcClientRegistrationAuthenticationProvider(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authorizationService cannot be null"); + } + + @Test + public void supportsWhenTypeJwtAuthenticationTokenThenReturnTrue() { + assertThat(this.authenticationProvider.supports(JwtAuthenticationToken.class)).isTrue(); + } + + @Test + public void authenticateWhenAccessTokenNotFoundThenThrowOAuth2AuthenticationException() { + JwtAuthenticationToken authentication = buildJwtAuthenticationToken("client-registration-token", "SCOPE_client.create"); + + when(authorizationService.findByToken( + eq("client-registration-token"), eq(OAuth2TokenType.ACCESS_TOKEN))) + .thenReturn(null); + + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .extracting("errorCode") + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + + } + + @Test + public void authenticateWhenAccessTokenInvalidatedThenThrowOAuth2AuthenticationException() { + + JwtAuthenticationToken authentication = buildJwtAuthenticationToken("client-registration-token", "SCOPE_client.create"); + + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + "client-registration-token", Instant.now().minusSeconds(120), Instant.now().plusSeconds(1000)); + + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization() + .token(accessToken, (metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) + .build(); + + when(authorizationService.findByToken( + eq("client-registration-token"), eq(OAuth2TokenType.ACCESS_TOKEN))) + .thenReturn(authorization); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .extracting("errorCode") + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + } + + @Test + public void authenticateWhenAccessTokenWithoutClientCreateScopeThenThrowOAuth2AuthenticationException() { + + JwtAuthenticationToken authentication = buildJwtAuthenticationToken("client-registration-token", "SCOPE_scope1"); + + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + "client-registration-token", Instant.now().minusSeconds(120), Instant.now().plusSeconds(1000), + new HashSet<>(Collections.singletonList("scope1"))); + + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization() + .token(accessToken) + .build(); + + when(authorizationService.findByToken( + eq("client-registration-token"), eq(OAuth2TokenType.ACCESS_TOKEN))) + .thenReturn(authorization); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .extracting("errorCode") + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + } + + @Test + public void authenticateWhenValidAccessTokenThenInvalidated() { + JwtAuthenticationToken authentication = buildJwtAuthenticationToken("client-registration-token", "SCOPE_client.create"); + + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + "client-registration-token", Instant.now().minusSeconds(120), Instant.now().plusSeconds(1000), + new HashSet<>(Collections.singletonList("client.create"))); + + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization() + .token(accessToken) + .build(); + + when(authorizationService.findByToken( + eq("client-registration-token"), eq(OAuth2TokenType.ACCESS_TOKEN))) + .thenReturn(authorization); + + authenticationProvider.authenticate(authentication); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(authorizationService).save(authorizationCaptor.capture()); + + OAuth2Authorization capturedAuthorization = authorizationCaptor.getValue(); + + assertThat(capturedAuthorization.getAccessToken()).isNotNull(); + assertThat(capturedAuthorization.getAccessToken().isInvalidated()).isTrue(); + } + + private static JwtAuthenticationToken buildJwtAuthenticationToken(String tokenValue, String... authorities) { + Jwt jwt = Jwt.withTokenValue(tokenValue) + .header("alg", "none") + .claim("sub", "client") + .build(); + List grantedAuthorities = AuthorityUtils.createAuthorityList(authorities); + JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(jwt, grantedAuthorities); + jwtAuthenticationToken.setAuthenticated(true); + return jwtAuthenticationToken; + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepositoryTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepositoryTests.java index aae2cfd19..3d95966e5 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepositoryTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepositoryTests.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.server.authorization.client; import org.junit.Test; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import java.util.Arrays; import java.util.Collections; @@ -112,4 +114,77 @@ public void findByClientIdWhenNotFoundThenNull() { public void findByClientIdWhenNullThenThrowIllegalArgumentException() { assertThatThrownBy(() -> this.clients.findByClientId(null)).isInstanceOf(IllegalArgumentException.class); } + + @Test + public void saveNullRegisteredClientThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.clients.saveClient(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("registeredClient cannot be null"); + } + + @Test + public void saveRegisteredClientThenReturnsSavedRegisteredClientWhenSearchedById() { + RegisteredClient registeredClient = RegisteredClient.withId("new-client") + .clientId("new-client") + .clientSecret("secret") + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC) + .redirectUri("https://newclient.com") + .scope("scope1").build(); + + this.clients.saveClient(registeredClient); + + RegisteredClient savedClient = this.clients.findById("new-client"); + + assertThat(savedClient).isNotNull().isEqualTo(registeredClient); + } + + @Test + public void saveRegisteredClientThenReturnsSavedRegisteredClientWhenSearchedByClientId() { + RegisteredClient registeredClient = RegisteredClient.withId("id1") + .clientId("new-client-id") + .clientSecret("secret") + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC) + .redirectUri("https://newclient.com") + .scope("scope1").build(); + + this.clients.saveClient(registeredClient); + + RegisteredClient savedClient = this.clients.findByClientId("new-client-id"); + + assertThat(savedClient).isNotNull().isEqualTo(registeredClient); + } + + @Test + public void saveRegisteredClientWithExistingIdThrowIllegalArgumentException() { + assertThatThrownBy(() -> { + RegisteredClient registeredClient = RegisteredClient.withId("registration-1") + .clientId("new-client") + .clientSecret("secret") + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC) + .redirectUri("https://newclient.com") + .scope("scope1").build(); + + this.clients.saveClient(registeredClient); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Registered client must be unique. Found duplicate identifier"); + } + + @Test + public void saveRegisteredClientWithExistingClientIdThrowIllegalArgumentException() { + assertThatThrownBy(() -> { + RegisteredClient registeredClient = RegisteredClient.withId("new-client") + .clientId("client-1") + .clientSecret("secret") + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC) + .redirectUri("https://newclient.com") + .scope("scope1").build(); + + this.clients.saveClient(registeredClient); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Registered client must be unique. Found duplicate client identifier"); + } } 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 3997f2cf5..e229b4df6 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 @@ -37,6 +37,8 @@ public void constructorWhenDefaultThenDefaultsAreSet() { assertThat(providerSettings.jwkSetEndpoint()).isEqualTo("/oauth2/jwks"); assertThat(providerSettings.tokenRevocationEndpoint()).isEqualTo("/oauth2/revoke"); assertThat(providerSettings.tokenIntrospectionEndpoint()).isEqualTo("/oauth2/introspect"); + assertThat(providerSettings.oidcClientRegistrationEndpoint()).isEqualTo("/connect/register"); + assertThat(providerSettings.isOidClientRegistrationEndpointEnabled()).isFalse(); } @Test @@ -47,6 +49,7 @@ public void settingsWhenProvidedThenSet() { String tokenRevocationEndpoint = "/oauth2/v1/revoke"; String tokenIntrospectionEndpoint = "/oauth2/v1/introspect"; String issuer = "https://example.com:9000"; + String oidcClientRegistrationEndpoint = "/connect/v1/register"; ProviderSettings providerSettings = new ProviderSettings() .issuer(issuer) @@ -54,7 +57,10 @@ public void settingsWhenProvidedThenSet() { .tokenEndpoint(tokenEndpoint) .jwkSetEndpoint(jwkSetEndpoint) .tokenRevocationEndpoint(tokenRevocationEndpoint) - .tokenIntrospectionEndpoint(tokenIntrospectionEndpoint); + .tokenIntrospectionEndpoint(tokenIntrospectionEndpoint) + .tokenRevocationEndpoint(tokenRevocationEndpoint) + .isOidClientRegistrationEndpointEnabled(true) + .oidcClientRegistrationEndpoint(oidcClientRegistrationEndpoint); assertThat(providerSettings.issuer()).isEqualTo(issuer); assertThat(providerSettings.authorizationEndpoint()).isEqualTo(authorizationEndpoint); @@ -62,6 +68,8 @@ public void settingsWhenProvidedThenSet() { assertThat(providerSettings.jwkSetEndpoint()).isEqualTo(jwkSetEndpoint); assertThat(providerSettings.tokenRevocationEndpoint()).isEqualTo(tokenRevocationEndpoint); assertThat(providerSettings.tokenIntrospectionEndpoint()).isEqualTo(tokenIntrospectionEndpoint); + assertThat(providerSettings.oidcClientRegistrationEndpoint()).isEqualTo(oidcClientRegistrationEndpoint); + assertThat(providerSettings.isOidClientRegistrationEndpointEnabled()).isTrue(); } @Test @@ -70,7 +78,7 @@ public void settingWhenCustomThenReturnAllSettings() { .setting("name1", "value1") .settings(settings -> settings.put("name2", "value2")); - assertThat(providerSettings.settings()).hasSize(7); + assertThat(providerSettings.settings()).hasSize(9); assertThat(providerSettings.setting("name1")).isEqualTo("value1"); assertThat(providerSettings.setting("name2")).isEqualTo("value2"); } @@ -115,6 +123,15 @@ public void tokenIntrospectionEndpointWhenNullThenThrowIllegalArgumentException( .withMessage("value cannot be null"); } + @Test + public void oidcClientRegistrationEndpointWhenNullThenThrowIllegalArgumentException() { + ProviderSettings settings = new ProviderSettings(); + assertThatIllegalArgumentException() + .isThrownBy(() -> settings.oidcClientRegistrationEndpoint(null)) + .withMessage("value cannot be null"); + } + + @Test public void jwksEndpointWhenNullThenThrowIllegalArgumentException() { ProviderSettings settings = new ProviderSettings(); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilterTests.java new file mode 100644 index 000000000..d8ae1d0ec --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilterTests.java @@ -0,0 +1,286 @@ +/* + * Copyright 2020-2021 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.oidc.web; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.AdditionalAnswers; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType; +import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; +import org.springframework.security.oauth2.core.oidc.OidcClientMetadataClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcClientRegistration; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link OidcClientRegistrationEndpointFilter} + * + * @author Ovidiu Popa + * @since 0.1.1 + */ +public class OidcClientRegistrationEndpointFilterTests { + + private static final OidcClientRegistration.Builder OIDC_CLIENT_REGISTRATION = OidcClientRegistration.builder() + .redirectUri("https://localhost:8080/client") + .responseType("code") + .grantType("authorization_code") + .tokenEndpointAuthenticationMethod("basic") + .scope("test"); + private final HttpMessageConverter errorHttpResponseConverter = + new OAuth2ErrorHttpMessageConverter(); + private static RegisteredClientRepository registeredClientRepository; + private static AuthenticationManager authenticationManager; + + @BeforeClass + public static void init() { + registeredClientRepository = mock(RegisteredClientRepository.class); + authenticationManager = mock(AuthenticationManager.class); + } + + @Before + public void setup() { + reset(registeredClientRepository); + reset(authenticationManager); + } + + @After + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OidcClientRegistrationEndpointFilter(null, + authenticationManager)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("registeredClientRepository cannot be null"); + } + + @Test + public void constructorWhenAuthenticationManagerNullThenThrowIllegalArgumentException() { + + assertThatThrownBy(() -> new OidcClientRegistrationEndpointFilter(registeredClientRepository, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authenticationManager cannot be null"); + } + + @Test + public void constructorWhenOidcClientRegistrationUriNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OidcClientRegistrationEndpointFilter(registeredClientRepository, authenticationManager, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("oidcClientRegistrationUri cannot be empty"); + } + + @Test + public void constructorWhenOidcClientRegistrationUriEmptyThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OidcClientRegistrationEndpointFilter(registeredClientRepository, authenticationManager, "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("oidcClientRegistrationUri cannot be empty"); + } + + @Test + public void doFilterWhenNotClientRegistrationRequestThenNotProcessed() throws Exception { + OidcClientRegistrationEndpointFilter filter = + new OidcClientRegistrationEndpointFilter(registeredClientRepository, authenticationManager); + + String requestUri = "/path"; + 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 doFilterWhenClientRegistrationRequestGetThenNotProcessed() throws Exception { + + OidcClientRegistrationEndpointFilter filter = + new OidcClientRegistrationEndpointFilter(registeredClientRepository, authenticationManager); + + String requestUri = OidcClientRegistrationEndpointFilter.DEFAULT_OIDC_CLIENT_REGISTRATION_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); + + verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class)); + } + + @Test + public void doFilterWhenAuthenticationManagerThrowsOAuth2AuthenticationExceptionThenBadRequest() throws Exception { + + setSecurityContext("client-registration-token", true, "SCOPE_client.create"); + + when(authenticationManager.authenticate(any(JwtAuthenticationToken.class))) + .thenThrow(new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT))); + + OidcClientRegistrationEndpointFilter filter = + new OidcClientRegistrationEndpointFilter(registeredClientRepository, authenticationManager); + + String requestUri = OidcClientRegistrationEndpointFilter.DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI; + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + request.setServletPath(requestUri); + + request.setContent(convertToByteArray(OIDC_CLIENT_REGISTRATION.build())); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + filter.doFilter(request, response, filterChain); + + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + OAuth2Error error = readError(response); + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + } + + @Test + @SuppressWarnings("unchecked") + public void doFilterWhenClientRegistrationRequestThenClientRegistrationResponse() throws Exception { + + doNothing().when(registeredClientRepository).saveClient(any(RegisteredClient.class)); + when(authenticationManager.authenticate(any(JwtAuthenticationToken.class))).then(AdditionalAnswers.returnsFirstArg()); + setSecurityContext("client-registration-token", true, "SCOPE_client.create"); + + OidcClientRegistrationEndpointFilter filter = + new OidcClientRegistrationEndpointFilter(registeredClientRepository, authenticationManager); + + String requestUri = OidcClientRegistrationEndpointFilter.DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI; + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + request.setServletPath(requestUri); + + request.setContent(convertToByteArray(OIDC_CLIENT_REGISTRATION.build())); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + filter.doFilter(request, response, filterChain); + + verifyNoInteractions(filterChain); + + verify(authenticationManager).authenticate(any()); + + ArgumentCaptor registeredClientCaptor = ArgumentCaptor.forClass(RegisteredClient.class); + verify(registeredClientRepository).saveClient(registeredClientCaptor.capture()); + + RegisteredClient registeredClient = registeredClientCaptor.getValue(); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.CREATED.value()); + assertThat(response.getContentType()).isEqualTo(MediaType.APPLICATION_JSON_VALUE); + + ObjectMapper objectMapper = new ObjectMapper(); + Map clientRegistrationResponse = objectMapper.readerFor(Map.class) + .readValue(response.getContentAsString()); + + assertThat(clientRegistrationResponse.get(OidcClientMetadataClaimNames.CLIENT_ID)) + .isEqualTo(registeredClient.getClientId()); + assertThat((String) clientRegistrationResponse.get(OidcClientMetadataClaimNames.CLIENT_SECRET)) + .isEqualTo(registeredClient.getClientSecret()); + assertThat((List) clientRegistrationResponse.get(OidcClientMetadataClaimNames.REDIRECT_URIS)) + .containsAll(registeredClient.getRedirectUris()); + assertThat(clientRegistrationResponse.get(OidcClientMetadataClaimNames.CLIENT_ID_ISSUED_AT)) + .isNotNull(); + assertThat(clientRegistrationResponse.get(OidcClientMetadataClaimNames.CLIENT_SECRET_EXPIRES_AT)) + .isEqualTo(0.0); + assertThat((List) clientRegistrationResponse.get(OidcClientMetadataClaimNames.RESPONSE_TYPES)) + .contains(OAuth2AuthorizationResponseType.CODE.getValue()); + assertThat((List) clientRegistrationResponse.get(OidcClientMetadataClaimNames.GRANT_TYPES)) + .containsAll(grantTypes(registeredClient)); + + assertThat(clientRegistrationResponse.get(OidcClientMetadataClaimNames.SCOPE)) + .isEqualTo(String.join(" ", registeredClient.getScopes())); + assertThat(clientRegistrationResponse.get(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD)) + .isEqualTo(registeredClient.getClientAuthenticationMethods().iterator().next().getValue()); + } + + private List grantTypes(RegisteredClient registeredClient) { + return registeredClient.getAuthorizationGrantTypes().stream() + .map(AuthorizationGrantType::getValue) + .collect(Collectors.toList()); + } + + private static void setSecurityContext(String tokenValue, boolean authenticated, String... authorities) { + Jwt jwt = Jwt.withTokenValue(tokenValue) + .header("alg", "none") + .claim("sub", "client") + .build(); + List grantedAuthorities = AuthorityUtils.createAuthorityList(authorities); + JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(jwt, grantedAuthorities); + jwtAuthenticationToken.setAuthenticated(authenticated); + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(jwtAuthenticationToken); + SecurityContextHolder.setContext(securityContext); + } + + private static byte[] convertToByteArray(OidcClientRegistration clientRegistration) throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + + return objectMapper + .writerFor(Map.class) + .writeValueAsBytes(clientRegistration.getClaims()); + } + + private OAuth2Error readError(MockHttpServletResponse response) throws Exception { + MockClientHttpResponse httpResponse = new MockClientHttpResponse( + response.getContentAsByteArray(), HttpStatus.valueOf(response.getStatus())); + return this.errorHttpResponseConverter.read(OAuth2Error.class, httpResponse); + } +} diff --git a/samples/boot/oauth2-integration/authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/boot/oauth2-integration/authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java index 0c7df4a45..e5d3ce82f 100644 --- a/samples/boot/oauth2-integration/authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ b/samples/boot/oauth2-integration/authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java @@ -21,6 +21,11 @@ import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import sample.jose.Jwks; import org.springframework.context.annotation.Bean; @@ -85,4 +90,12 @@ public JWKSource jwkSource() { public ProviderSettings providerSettings() { return new ProviderSettings().issuer("http://auth-server:9000"); } + + @Bean + public JwtDecoder jwtDecoder(ProviderSettings providerSettings){ + OAuth2TokenValidator jwtValidator = JwtValidators.createDefaultWithIssuer(providerSettings.issuer()); + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri("http://auth-server:9000"+providerSettings.jwkSetEndpoint()).build(); + jwtDecoder.setJwtValidator(jwtValidator); + return jwtDecoder; + } }