Spring OAuth2 Client - authorization code exchange fails

1.9k views Asked by At

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
        );
    }
}
3

There are 3 answers

0
Will Phi On BEST ANSWER

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.

7
ch4mp On

You can't have all routes requiring an authenticated user in an OAuth2 client using authorization code flow: in your case (which is following the default Spring Boot convention) /login/** and /oauth2/** are accessed during the user authentication process, which means before the user is authenticated.

@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
                    .requestMatchers("/login/**", "/oauth2/**").permitAll()
                    .anyRequest().authenticated()
            );
        
        return http.build();
    }
}

But this requires to meet the requirements when providing a loginPage (providing with a @Controller for it, to start with).

Provided that you have spring-boot-starter-oauth2-client in the classpath and Spring Boot OAuth2 client properties in application.properties (or yaml), it seems that this would be just fine:

@Configuration
public class SecurityConfig {
}

I suggest you have a look at my tutorials for your clients (and resource servers) configuration.

0
Inquisitive On

The answer provided by Will Phi is correct.

Browsers map cookies to the host (port is not taken into account), therefore after redirect to localhost:8080 JSESSIONID cookie gets overwritten and the client is not able to recognize it.

As Will Phi has pointed out, to resolve the problem we can set an alias to 127.0.0.1 in the hosts file (/etc/hosts - Linux, Windows\System32\drivers\etc\hosts - Windows).

There's another solution, instead we can simply use paths starting with 127.0.0.1:7070 for client and paths starting with localhost:8080 for authorization server. I.e. client registration would look like that:

@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)
        .redirectUri("http://127.0.0.1:7070/login/oauth2/code/main-client")
        .authorizationUri("http://localhost:8080/oauth2/authorize")
        .tokenUri("http://localhost:8080/oauth2/token")
        .build();
}

And 127.0.0.1:7070 also should be used in the redirect URI in client-configuration of the authorization server.