Search code examples
javaspringspring-securityspring-security-oauth2oauth2client

Spring OAuth2 Client - authorization code exchange fails


Suppose we have a confidential OAuth2 Client performing authorization against the Authorization Server using authorization code grant type.

Here's a minimal reproducible example.

Client application is running on port 7070, authorization server on 8080.

Client configuration:

@Configuration
public class ClientsConfig {
    
    @Bean
    public ClientRegistration mainWebClient() {
        
        return ClientRegistration
            .withRegistrationId("main-client")
            .clientId("test_web_client")
            .clientName("test_web_client")
            .clientSecret("secret")
            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .scope(OidcScopes.OPENID)
            .authorizationUri("http://localhost:8080/oauth2/authorize")
            .tokenUri("http://localhost:8080/oauth2/token")
            .redirectUri("http://localhost:7070/login/oauth2/code/main-client")
            .build();
    }
    
    @Bean
    public ClientRegistrationRepository clientRegistrationRepository() {
        
        return new InMemoryClientRegistrationRepository(
            mainWebClient()
        );
    }
    
    @Bean
    public OAuth2AuthorizedClientManager oauth2AuthorizedClientManager(
        OAuth2AuthorizedClientRepository authorizedClientRepository) {
        
        var clientManager =
            new DefaultOAuth2AuthorizedClientManager(
                clientRegistrationRepository(),
                authorizedClientRepository
            );
        
        clientManager
            .setAuthorizedClientProvider(
                OAuth2AuthorizedClientProviderBuilder.builder()
                    .authorizationCode()
                    .refreshToken()
                    .build()
            );
        
        return clientManager;
    }
}

SecurityFilterChain - configured login page and for simplicity every request is permitted (OAuth2AuthorizedClient client injected in controller as a method argument would trigger the authorization flow):

@Configuration
public class SecurityConfig {
    
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        
        http.oauth2Client(withDefaults())
            .oauth2Login(oauth2Login ->
                oauth2Login.loginPage("/oauth2/authorization/main-client")
            );
        
        http.authorizeHttpRequests(authorize ->
                authorize.anyRequest().permitAll()
            );
        
        return http.build();
    }
}

Simplistic controller with OAuth2AuthorizedClient injected:

@RestController
public class TokenController {
    
    @GetMapping("/hello")
    public String getToken(
        @RegisteredOAuth2AuthorizedClient("main-client")
        OAuth2AuthorizedClient client) {
        
        return "access token: " +
            client.getAccessToken().getTokenType() + " " +
            client.getAccessToken().getTokenValue();
    }
}

It's important to emphasize that the problem is rooted in the client's configuration.

Authorization server does its job flawlessly issuing codes and tokens (specifically for the client shown above, server behavior was verified using browser to obtain authorization code and Postman to exchange the code for an access token).

For some reason, the client application is not capable to complete the authorization flow. It receives an authorization code from the authorization server but fails to exchange it.

Here's what happens. Firstly, as expected, the unauthorized request gets redirected to the authorization server running on port 8080.

Redirect to Authorization server

User credentials provided to the server, and it responds with the authorization code, after receiving the code authorization attempt fails and client application perform the second redirect to the authorization server.

Authorization attempt fails, second redirect

In the client's logs I found that an OAuth2AuthenticationException with the error-code authorization_request_not_found occurred in the OAuth2LoginAuthenticationFilter while invoking attemptAuthentication() method.

Here are log-messages starting from the point when authorization code was received:

2023-06-15T22:28:23.268+03:00 DEBUG 7732 --- [nio-7070-exec-3] o.s.security.web.FilterChainProxy : Securing GET /login/oauth2/code/main-client?code=Dpv-xhYJZcdp4T6B3VoRQZ1UzXR7w9SR2rWYiEUUO5XtGWwJvzxV-WqR2hVwG5Sc1OmsciBkGau65d4Lf7RX5YIoTseudmI6qJbdsBMM7dN2iHnDwXLnsSCAu0WhbS6_&state=EKLnVsMKTT6jx4RrBn3wCbJ-a7mT0ee4xHwgAmEPI5o%3D 2023-06-15T22:28:23.269+03:00 TRACE 7732 --- [nio-7070-exec-3] o.s.security.web.FilterChainProxy : Invoking DisableEncodeUrlFilter (1/15) 2023-06-15T22:28:23.269+03:00 TRACE 7732 --- [nio-7070-exec-3] o.s.security.web.FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/15) 2023-06-15T22:28:23.269+03:00 TRACE 7732 --- [nio-7070-exec-3] o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderFilter (3/15) 2023-06-15T22:28:23.269+03:00 TRACE 7732 --- [nio-7070-exec-3] o.s.security.web.FilterChainProxy : Invoking HeaderWriterFilter (4/15) 2023-06-15T22:28:23.269+03:00 TRACE 7732 --- [nio-7070-exec-3] o.s.security.web.FilterChainProxy : Invoking CsrfFilter (5/15) 2023-06-15T22:28:23.269+03:00 TRACE 7732 --- [nio-7070-exec-3] o.s.security.web.csrf.CsrfFilter : Did not protect against CSRF since request did not match CsrfNotRequired [TRACE, HEAD, GET, OPTIONS] 2023-06-15T22:28:23.269+03:00 TRACE 7732 --- [nio-7070-exec-3] o.s.security.web.FilterChainProxy : Invoking LogoutFilter (6/15) 2023-06-15T22:28:23.269+03:00 TRACE 7732 --- [nio-7070-exec-3] o.s.s.w.a.logout.LogoutFilter : Did not match request to Ant [pattern='/logout', POST] 2023-06-15T22:28:23.269+03:00 TRACE 7732 --- [nio-7070-exec-3] o.s.security.web.FilterChainProxy : Invoking OAuth2AuthorizationRequestRedirectFilter (7/15) 2023-06-15T22:28:23.269+03:00 TRACE 7732 --- [nio-7070-exec-3] o.s.security.web.FilterChainProxy : Invoking OAuth2AuthorizationRequestRedirectFilter (8/15) 2023-06-15T22:28:23.270+03:00 TRACE 7732 --- [nio-7070-exec-3] o.s.security.web.FilterChainProxy : Invoking OAuth2LoginAuthenticationFilter (9/15) 2023-06-15T22:28:23.271+03:00 TRACE 7732 --- [nio-7070-exec-3] .s.o.c.w.OAuth2LoginAuthenticationFilter : Failed to process authentication request

org.springframework.security.oauth2.core.OAuth2AuthenticationException: [authorization_request_not_found] at org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter.attemptAuthentication(OAuth2LoginAuthenticationFilter.java:173) ~[spring-security-oauth2-client-6.1.0.jar:6.1.0] at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:231) ~[spring-security-web-6.1.0.jar:6.1.0] at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:221) ~[spring-security-web-6.1.0.jar:6.1.0]

*** few lines omitted ***

2023-06-15T22:28:23.272+03:00 TRACE 7732 --- [nio-7070-exec-3] .s.o.c.w.OAuth2LoginAuthenticationFilter : Cleared SecurityContextHolder 2023-06-15T22:28:23.272+03:00 TRACE 7732 --- [nio-7070-exec-3] .s.o.c.w.OAuth2LoginAuthenticationFilter : Handling authentication failure 2023-06-15T22:28:23.272+03:00 DEBUG 7732 --- [nio-7070-exec-3] o.s.s.web.DefaultRedirectStrategy : Redirecting to /oauth2/authorization/main-client?error

*** few lines omitted ***

2023-06-15T22:28:23.304+03:00 TRACE 7732 --- [nio-7070-exec-4] o.s.security.web.FilterChainProxy : Invoking OAuth2AuthorizationRequestRedirectFilter (7/15) 2023-06-15T22:28:23.305+03:00 DEBUG 7732 --- [nio-7070-exec-4] o.s.s.web.DefaultRedirectStrategy : Redirecting to http://localhost:8080/oauth2/authorize?response_type=code&client_id=test_web_client&scope=openid&state=norrUwgtNOpX4olrhN7-nPyVoBhmGYWSU5NJppnjxGA%3D&redirect_uri=http://localhost:7070/login/oauth2/code/main-client&nonce=6fiTfM0ul1ASzKKf581SUvy092AN4Jq1Vg_a97FMMqs 2023-06-15T22:28:23.305+03:00 TRACE 7732 --- [nio-7070-exec-4] o.s.s.w.header.writers.HstsHeaderWriter : Not injecting HSTS header since it did not match request to [Is Secure]

Can you please give me hint how can it be fixed?

Note

To verify the behavior I've described, any sort of OAuth2 authorization server can be used.

Just for the sake of completeness, here's a minimal configuration of the Spring OAuth2 Authorization Server which is aware of the client shown previously.

@Configuration
public class SecurityConfig {
    
    @Order(1)
    @Bean
    public SecurityFilterChain authSecurityFilterChain(HttpSecurity http) throws Exception {
        
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
            .authorizationEndpoint(endPoint -> endPoint
                .authenticationProviders(LocalhostCompliantValidator::apply)
            )
            .oidc(Customizer.withDefaults());
        
        http.exceptionHandling(c ->
            c.authenticationEntryPoint(
                new LoginUrlAuthenticationEntryPoint("/login")
            )
        );
        
        return http.build();
    }
    
    @Order(2)
    @Bean
    public SecurityFilterChain appSecurityFilterChain(HttpSecurity http) throws Exception {
        
        http.formLogin(Customizer.withDefaults());
        
        http.authorizeHttpRequests(auth ->
            auth.anyRequest().authenticated()
        );
        
        return http.build();
    }
    
    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder().build();
    }
    
    @Bean
    public UserDetailsService userDetailsService() {
        var user = User.withUsername("user")
            .password("password")
            .authorities("read", "write")
            .build();
        
        return new InMemoryUserDetailsManager(user);
    }
    
    @Bean
    @SuppressWarnings("deprecation")
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
    
    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient mainClient = RegisteredClient.withId("1")
            .clientId("test_web_client")
            .clientSecret("secret")
            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
            .scope(OidcScopes.OPENID)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .redirectUri("http://localhost:7070/login/oauth2/code/main-client")
            .tokenSettings(
                TokenSettings.builder()
                    .accessTokenFormat(OAuth2TokenFormat.REFERENCE)
                    .accessTokenTimeToLive(Duration.ofHours(24))
                    .build()
            )
            .clientSettings(
                ClientSettings.builder()
                    .requireProofKey(false)
                    .requireAuthorizationConsent(false)
                    .build()
            )
            .build();
        
        return new InMemoryRegisteredClientRepository(mainClient);
    }
}

Using localhost as a redirect URI is against specification and therefore in order to work with the client on localhost:7070 requires implementing a custom validator, which might look like this:

public class LocalhostCompliantValidator implements Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> {
    public static void apply(List<AuthenticationProvider> providers) {
        
        for (var provider: providers) {
            if (provider instanceof OAuth2AuthorizationCodeRequestAuthenticationProvider oauth2Provider) {
                oauth2Provider.setAuthenticationValidator(new LocalhostCompliantValidator());
            }
        }
    }
    
    @Override
    public void accept(OAuth2AuthorizationCodeRequestAuthenticationContext context) {
        
        var token = getAuthenticationToken(context);
        
        boolean clientHasRequestedUri = getRegisteredUris(context)
            .contains(token.getRedirectUri());
        
        if (!clientHasRequestedUri) {
            throwAuthenticationException(token);
        }
    }
    
    private static Set<String> getRegisteredUris(OAuth2AuthorizationCodeRequestAuthenticationContext context) {
        return context
            .getRegisteredClient()
            .getRedirectUris();
    }
    
    private static OAuth2AuthorizationCodeRequestAuthenticationToken getAuthenticationToken(OAuth2AuthorizationCodeRequestAuthenticationContext context) {
        
        return context.getAuthentication();
    }
    
    private static void throwAuthenticationException(OAuth2AuthorizationCodeRequestAuthenticationToken token) {
        throw new OAuth2AuthorizationCodeRequestAuthenticationException(
            new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST), token
        );
    }
}

Solution

  • That is usually because of cookie overwriting, see:

    https://github.com/spring-projects/spring-security/issues/5946

    Add a line to hosts file:

    127.0.0.1 auth-server
    

    And then change "localhost:8080" to "auth-server:8080" in ClientsConfig.