Improve customizing OIDC Client Registration endpoint

Related gh-696

Closes gh-946
This commit is contained in:
Daniel Garnier-Moiroux
2022-10-27 14:11:11 +02:00
committed by Joe Grandja
parent 2ba711c83a
commit efbfdc234c
5 changed files with 498 additions and 61 deletions

View File

@@ -18,6 +18,10 @@ package org.springframework.security.oauth2.server.authorization.config.annotati
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.List;
import java.util.function.Consumer;
import javax.servlet.http.HttpServletResponse;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
@@ -30,6 +34,7 @@ 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;
@@ -37,6 +42,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
@@ -45,6 +51,7 @@ import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
import org.springframework.mock.http.MockHttpOutputMessage;
import org.springframework.mock.http.client.MockClientHttpResponse;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -54,6 +61,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
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.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
@@ -76,11 +84,17 @@ import org.springframework.security.oauth2.server.authorization.client.Registere
import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.oidc.OidcClientRegistration;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcClientConfigurationAuthenticationProvider;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcClientRegistrationAuthenticationProvider;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcClientRegistrationAuthenticationToken;
import org.springframework.security.oauth2.server.authorization.oidc.http.converter.OidcClientRegistrationHttpMessageConverter;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.test.SpringTestRule;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
@@ -88,6 +102,14 @@ import org.springframework.web.util.UriComponentsBuilder;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.CoreMatchers.containsString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
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;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
@@ -127,6 +149,18 @@ public class OidcClientRegistrationTests {
@Autowired
private AuthorizationServerSettings authorizationServerSettings;
private static AuthenticationConverter authenticationConverter;
private static Consumer<List<AuthenticationConverter>> authenticationConvertersConsumer;
private static AuthenticationProvider authenticationProvider;
private static Consumer<List<AuthenticationProvider>> authenticationProvidersConsumer;
private static AuthenticationSuccessHandler authenticationSuccessHandler;
private static AuthenticationFailureHandler authenticationFailureHandler;
private MockWebServer server;
private String clientJwkSetUrl;
@@ -144,6 +178,12 @@ public class OidcClientRegistrationTests {
.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
.addScript("org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
.build();
authenticationConverter = mock(AuthenticationConverter.class);
authenticationConvertersConsumer = mock(Consumer.class);
authenticationProvider = mock(AuthenticationProvider.class);
authenticationProvidersConsumer = mock(Consumer.class);
authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class);
authenticationFailureHandler = mock(AuthenticationFailureHandler.class);
}
@Before
@@ -157,6 +197,7 @@ public class OidcClientRegistrationTests {
.setBody(clientJwkSet.toString());
// @formatter:on
this.server.enqueue(response);
when(authenticationProvider.supports(OidcClientRegistrationAuthenticationToken.class)).thenReturn(true);
}
@After
@@ -164,6 +205,12 @@ public class OidcClientRegistrationTests {
this.server.shutdown();
jdbcOperations.update("truncate table oauth2_authorization");
jdbcOperations.update("truncate table oauth2_registered_client");
reset(authenticationConverter);
reset(authenticationConvertersConsumer);
reset(authenticationProvider);
reset(authenticationProvidersConsumer);
reset(authenticationSuccessHandler);
reset(authenticationFailureHandler);
}
@AfterClass
@@ -260,6 +307,65 @@ public class OidcClientRegistrationTests {
assertThat(clientConfigurationResponse.getRegistrationAccessToken()).isNull();
}
@Test
public void requestWhenUserInfoEndpointCustomizedThenUsed() throws Exception {
this.spring.register(CustomClientRegistrationConfiguration.class).autowire();
// @formatter:off
OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
.clientName("client-name")
.redirectUri("https://client.example.com")
.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
.scope("scope1")
.scope("scope2")
.build();
// @formatter:on
doAnswer(invocation -> {
HttpServletResponse response = invocation.getArgument(1, HttpServletResponse.class);
ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
httpResponse.setStatusCode(HttpStatus.CREATED);
new OidcClientRegistrationHttpMessageConverter().write(clientRegistration, null, httpResponse);
return null;
}).when(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), any());
registerClient(clientRegistration);
verify(authenticationConverter).convert(any());
ArgumentCaptor<List<AuthenticationConverter>> authenticationConvertersCaptor = ArgumentCaptor
.forClass(List.class);
verify(authenticationConvertersConsumer).accept(authenticationConvertersCaptor.capture());
List<AuthenticationConverter> authenticationConverters = authenticationConvertersCaptor.getValue();
assertThat(authenticationConverters).hasSize(2).contains(authenticationConverter);
verify(authenticationProvider).authenticate(any());
ArgumentCaptor<List<AuthenticationProvider>> authenticationProvidersCaptor = ArgumentCaptor
.forClass(List.class);
verify(authenticationProvidersConsumer).accept(authenticationProvidersCaptor.capture());
List<AuthenticationProvider> authenticationProviders = authenticationProvidersCaptor.getValue();
assertThat(authenticationProviders).hasSize(3)
.allMatch(provider -> provider == authenticationProvider
|| provider instanceof OidcClientRegistrationAuthenticationProvider
|| provider instanceof OidcClientConfigurationAuthenticationProvider);
verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), any());
verifyNoInteractions(authenticationFailureHandler);
}
@Test
public void requestWhenUserInfoEndpointCustomizedAndErrorThenUsed() throws Exception {
this.spring.register(CustomClientRegistrationConfiguration.class).autowire();
when(authenticationProvider.authenticate(any())).thenThrow(new OAuth2AuthenticationException("error"));
this.mvc.perform(get(DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI)
.param(OAuth2ParameterNames.CLIENT_ID, "invalid").with(jwt()));
verify(authenticationFailureHandler).onAuthenticationFailure(any(), any(), any());
verifyNoInteractions(authenticationSuccessHandler);
}
private OidcClientRegistration registerClient(OidcClientRegistration clientRegistration) throws Exception {
// ***** (1) Obtain the "initial" access token used for registering the client
@@ -352,6 +458,32 @@ public class OidcClientRegistrationTests {
return clientRegistrationHttpMessageConverter.read(OidcClientRegistration.class, httpResponse);
}
@EnableWebSecurity
static class CustomClientRegistrationConfiguration extends AuthorizationServerConfiguration {
@Bean
@Override
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer();
authorizationServerConfigurer.oidc(oidc -> oidc.clientRegistrationEndpoint(
clientRegistration -> clientRegistration.clientRegistrationRequestConverter(authenticationConverter)
.clientRegistrationRequestConverters(authenticationConvertersConsumer)
.authenticationProvider(authenticationProvider)
.authenticationProviders(authenticationProvidersConsumer)
.clientRegistrationResponseHandler(authenticationSuccessHandler)
.errorResponseHandler(authenticationFailureHandler)));
RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
http.requestMatcher(endpointsMatcher)
.authorizeRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated())
.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt).apply(authorizationServerConfigurer);
return http.build();
}
}
@EnableWebSecurity
static class AuthorizationServerConfiguration {

View File

@@ -15,10 +15,12 @@
*/
package org.springframework.security.oauth2.server.authorization.oidc.web;
import java.io.IOException;
import java.time.Instant;
import java.util.Collections;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@@ -33,6 +35,8 @@ 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.authentication.TestingAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
@@ -54,10 +58,14 @@ import org.springframework.security.oauth2.server.authorization.oidc.OidcClientR
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcClientRegistrationAuthenticationToken;
import org.springframework.security.oauth2.server.authorization.oidc.http.converter.OidcClientRegistrationHttpMessageConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@@ -68,6 +76,7 @@ import static org.mockito.Mockito.when;
*
* @author Ovidiu Popa
* @author Joe Grandja
* @author Daniel Garnier-Moiroux
*/
public class OidcClientRegistrationEndpointFilterTests {
private static final String DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI = "/connect/register";
@@ -103,6 +112,27 @@ public class OidcClientRegistrationEndpointFilterTests {
.withMessage("clientRegistrationEndpointUri cannot be empty");
}
@Test
public void setAuthenticationConverterWhenNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> this.filter.setAuthenticationConverter(null))
.withMessage("authenticationConverter cannot be null");
}
@Test
public void setAuthenticationSuccessHandlerWhenNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> this.filter.setAuthenticationSuccessHandler(null))
.withMessage("authenticationSuccessHandler cannot be null");
}
@Test
public void setAuthenticationFailureHandlerWhenNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> this.filter.setAuthenticationFailureHandler(null))
.withMessage("authenticationFailureHandler cannot be null");
}
@Test
public void doFilterWhenNotClientRegistrationRequestThenNotProcessed() throws Exception {
String requestUri = "/path";
@@ -203,25 +233,13 @@ public class OidcClientRegistrationEndpointFilterTests {
@Test
public void doFilterWhenClientRegistrationRequestValidThenSuccessResponse() throws Exception {
// @formatter:off
OidcClientRegistration.Builder clientRegistrationBuilder = OidcClientRegistration.builder()
.clientName("client-name")
.redirectUri("https://client.example.com")
.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
.scope("scope1")
.scope("scope2");
OidcClientRegistration expectedClientRegistrationResponse = createClientRegistration();
OidcClientRegistration clientRegistrationRequest = clientRegistrationBuilder.build();
OidcClientRegistration expectedClientRegistrationResponse = clientRegistrationBuilder
.clientId("client-id")
.clientIdIssuedAt(Instant.now())
.clientSecret("client-secret")
.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
.responseType(OAuth2AuthorizationResponseType.CODE.getValue())
.idTokenSignedResponseAlgorithm(SignatureAlgorithm.RS256.getName())
.registrationAccessToken("registration-access-token")
.registrationClientUrl("https://auth-server:9000/connect/register?client_id=client-id")
OidcClientRegistration clientRegistrationRequest = OidcClientRegistration.builder()
.clientName(expectedClientRegistrationResponse.getClientName())
.redirectUris(redirectUris -> redirectUris.addAll(expectedClientRegistrationResponse.getRedirectUris()))
.grantTypes(grantTypes -> grantTypes.addAll(expectedClientRegistrationResponse.getGrantTypes()))
.scopes(scopes -> scopes.addAll(expectedClientRegistrationResponse.getScopes()))
.build();
// @formatter:on
@@ -353,6 +371,27 @@ public class OidcClientRegistrationEndpointFilterTests {
OAuth2ErrorCodes.INVALID_CLIENT, HttpStatus.UNAUTHORIZED);
}
@Test
public void doFilterWhenCustomAuthenticationFailureHandlerThenUsed() throws Exception {
AuthenticationFailureHandler authenticationFailureHandler = mock(AuthenticationFailureHandler.class);
this.filter.setAuthenticationFailureHandler(authenticationFailureHandler);
when(this.authenticationManager.authenticate(any()))
.thenThrow(new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_TOKEN));
String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
request.setServletPath(requestUri);
request.setParameter(OAuth2ParameterNames.CLIENT_ID, "client1");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain filterChain = mock(FilterChain.class);
this.filter.doFilter(request, response, filterChain);
verify(authenticationFailureHandler).onAuthenticationFailure(eq(request), eq(response),
any(OAuth2AuthenticationException.class));
}
private void doFilterWhenClientConfigurationRequestInvalidThenError(
String errorCode, HttpStatus status) throws Exception {
Jwt jwt = createJwt("client.read");
@@ -384,23 +423,7 @@ public class OidcClientRegistrationEndpointFilterTests {
@Test
public void doFilterWhenClientConfigurationRequestValidThenSuccessResponse() throws Exception {
// @formatter:off
OidcClientRegistration expectedClientRegistrationResponse = OidcClientRegistration.builder()
.clientId("client-id")
.clientIdIssuedAt(Instant.now())
.clientSecret("client-secret")
.clientName("client-name")
.redirectUri("https://client.example.com")
.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
.responseType(OAuth2AuthorizationResponseType.CODE.getValue())
.idTokenSignedResponseAlgorithm(SignatureAlgorithm.RS256.getName())
.scope("scope1")
.scope("scope2")
.registrationClientUrl("https://auth-server:9000/connect/register?client_id=client-id")
.build();
// @formatter:on
OidcClientRegistration expectedClientRegistrationResponse = createClientRegistration();
Jwt jwt = createJwt("client.read");
JwtAuthenticationToken principal = new JwtAuthenticationToken(
@@ -452,6 +475,74 @@ public class OidcClientRegistrationEndpointFilterTests {
.isEqualTo(expectedClientRegistrationResponse.getRegistrationClientUrl());
}
@Test
public void doFilterWhenCustomAuthenticationSuccessHandlerThenUsed() throws Exception {
OidcClientRegistration expectedClientRegistrationResponse = createClientRegistration();
Authentication principal = new TestingAuthenticationToken("principal", "Credentials");
OidcClientRegistrationAuthenticationToken clientRegistrationAuthenticationResult =
new OidcClientRegistrationAuthenticationToken(principal, expectedClientRegistrationResponse);
when(this.authenticationManager.authenticate(any())).thenReturn(clientRegistrationAuthenticationResult);
AuthenticationSuccessHandler successHandler = mock(AuthenticationSuccessHandler.class);
this.filter.setAuthenticationSuccessHandler(successHandler);
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(principal);
SecurityContextHolder.setContext(securityContext);
String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
request.setServletPath(requestUri);
request.setParameter(OAuth2ParameterNames.CLIENT_ID, expectedClientRegistrationResponse.getClientId());
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain filterChain = mock(FilterChain.class);
this.filter.doFilter(request, response, filterChain);
verify(successHandler).onAuthenticationSuccess(request, response, clientRegistrationAuthenticationResult);
}
private static OidcClientRegistration createClientRegistration() {
// @formatter:off
OidcClientRegistration expectedClientRegistrationResponse = OidcClientRegistration.builder()
.clientId("client-id")
.clientIdIssuedAt(Instant.now())
.clientSecret("client-secret")
.clientName("client-name")
.redirectUri("https://client.example.com")
.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
.responseType(OAuth2AuthorizationResponseType.CODE.getValue())
.idTokenSignedResponseAlgorithm(SignatureAlgorithm.RS256.getName())
.scope("scope1")
.scope("scope2")
.registrationClientUrl("https://auth-server:9000/connect/register?client_id=client-id")
.build();
return expectedClientRegistrationResponse;
// @formatter:on
}
@Test
public void doFilterWhenCustomAuthenticationConverterThenUsed() throws ServletException, IOException {
AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class);
this.filter.setAuthenticationConverter(authenticationConverter);
String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
request.setServletPath(requestUri);
request.setParameter(OAuth2ParameterNames.CLIENT_ID, "client-id");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain filterChain = mock(FilterChain.class);
this.filter.doFilter(request, response, filterChain);
verify(authenticationConverter).convert(request);
}
private OAuth2Error readError(MockHttpServletResponse response) throws Exception {
MockClientHttpResponse httpResponse = new MockClientHttpResponse(
response.getContentAsByteArray(), HttpStatus.valueOf(response.getStatus()));