Describe the bug ServletOAuth2AuthorizedClientExchangeFilterFunction with AuthenticatedPrincipalOAuth2AuthorizedClientRepository never removes an invalid token if an origin princapal is authenticated. The problem is that the ServletOAuth2AuthorizedClientExchangeFilterFunction creates a new Authentication if a token is invalid and a resource server returns 401. This new Authentication is created in AuthorizationFailureForwarder and it is not authenticated. And because there is a check for authentication in AuthenticatedPrincipalOAuth2AuthorizedClientRepository and origin token was authenticated and it is stored for example in InMemoryOAuth2AuthorizedClientService but the new one is not authenticated then the AuthenticatedPrincipalOAuth2AuthorizedClientRepository tries to remove the token from the default different HttpSessionOAuth2AuthorizedClientRepository.

See the methods implementations: https://github.com/spring-projects/spring-security/blob/7ef3f619242816683a72b35a1f8b4fb4f32d5203/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServletOAuth2AuthorizedClientExchangeFilterFunction.java#L618-L632

https://github.com/spring-projects/spring-security/blob/857830f6958c229650e6a00d376af38c8bcddbad/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/AuthenticatedPrincipalOAuth2AuthorizedClientRepository.java#L98-L112

To Reproduce Use an Authentication with and set authentication true. And set it into SecurityContext. For example:

class KeycloakAuthentication extends AbstractAuthenticationToken {

  public KeycloakAuthentication() {
    super(null);
    setAuthenticated(true);
  }

  @Override
  public Object getCredentials() {
    return "";
  }

  @Override
  public Object getPrincipal() {
    return "keycloak";
  }

}

Use WebClient configured with ServletOAuth2AuthorizedClientExchangeFilterFunction where authorizedClientRepository is AuthenticatedPrincipalOAuth2AuthorizedClientRepository

final ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client = new ServletOAuth2AuthorizedClientExchangeFilterFunction(
      clientRegistrationRepository, authorizedClientRepository);
  oauth2Client.setDefaultClientRegistrationId(clientName);

WebClient webClient = WebClient.builder()
    .apply(oauth2Client.oauth2Configuration())
    .build();

Call it the first time with a success response from a resource server. Call it the second time with 401 from a resource server. And invalid OAuth2 token is still there and you have to wait until expiration.

Expected behavior OAuth2 token is removed even if an Authentication is authenticated.

Sample I will try to create it.

Comment From: jgrandja

@Saljack Thanks for the report. However, I don't think I'm following your use case. It would be most efficient if you can provide a minimal sample (or test) that reproduces the issue.

Comment From: Saljack

Ok I finally create a sample here: https://github.com/Saljack/spring-security-9477 See a SpringSecurity9477ApplicationTests where I try simulate a resource server which returns 401 and then a token should be removed but it will never be removed. The Best way how to see it is debug it and put a break point into the method removeAuthorizedClient in AuthenticatedPrincipalOAuth2AuthorizedClientRepository https://github.com/spring-projects/spring-security/blob/857830f6958c229650e6a00d376af38c8bcddbad/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/AuthenticatedPrincipalOAuth2AuthorizedClientRepository.java#L98-L112

Comment From: jgrandja

@Saljack Thanks for creating the sample.

The issue here is a misconfiguration of the WebClient @Bean and associated ServletOAuth2AuthorizedClientExchangeFilterFunction.

Replace your existing WebClient @Bean with the following:

  @Bean
  public OAuth2AuthorizedClientManager authorizedClientManager(
          ClientRegistrationRepository clientRegistrationRepository,
          OAuth2AuthorizedClientService authorizedClientService) {

    OAuth2AuthorizedClientProvider authorizedClientProvider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                    .clientCredentials()
                    .build();

    AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager =
            new AuthorizedClientServiceOAuth2AuthorizedClientManager(
                    clientRegistrationRepository, authorizedClientService);
    authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

    return authorizedClientManager;
  }

  @Bean
  public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
    final ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
            new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
    oauth2Client.setDefaultClientRegistrationId("test");

    return WebClient.builder()
            .apply(oauth2Client.oauth2Configuration())
            .build();
  }

Given that your sample is using a client_credentials client, you need to configure a AuthorizedClientServiceOAuth2AuthorizedClientManager instead of a DefaultOAuth2AuthorizedClientManager.

Please review the reference for OAuth2AuthorizedClientManager.

Take note of:

The DefaultOAuth2AuthorizedClientManager is designed to be used within the context of a HttpServletRequest. When operating outside of a HttpServletRequest context, use AuthorizedClientServiceOAuth2AuthorizedClientManager instead.

After you apply the above change, you will need to fix your test in SpringSecurity9477ApplicationTests.

The test has the following:

    // Second request and resource server returns 401 Unauthorized then the token
    // should be removed
    webClient
        .get()
        .uri("/")
        .headers(headers -> headers.setBasicAuth("user", "password"))
        .exchange()
        .expectStatus()
        .is5xxServerError();
    loadAuthorizedClient = oauth2AuthorizedClientService.loadAuthorizedClient("test", "user");
    assertNull(loadAuthorizedClient, "Token is not removed");

Calling the Resource Server using an invalid token (e.g. expired) should return a 401 NOT a 500. Also, you shouldn't pass .headers(headers -> headers.setBasicAuth("user", "password")). Please update the test to:

    // Second request and resource server returns 401 Unauthorized then the token
    // should be removed
    webClient
        .get()
        ....
        .is4xxClientError()

NOTE: The clientResponseHandler in ServletOAuth2AuthorizedClientExchangeFilterFunction will only be applied on a successful response, e.g. 401. It will not be applied on a 500 server error. You will need to simulate the mock server to return a 401.

I'm going to close this based on application misconfiguration.