I have a Spring Boot application (my-app-1) where users can login through Keycloak via OIDC. This is what the configuration looks like:

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: https://my.keycloak.com/realms/xyz
        registration:
          keycloak:
            provider: keycloak
            client-id: my-app-1
            client-secret: my-app-1
            authorization-grant-type: authorization_code
            scope:
              - openid
              - profile
              - email
              - stuff.read
          my-app-2:
            provider: keycloak
            client-id: my-app-2
            client-secret: my-app-2
            authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange
            scope:
              - stuff.read

I was thrilled to see that support for Token Exchange had landed in Spring Security 6.3. Maybe I'm missing something, but I don't think the current implementation allows me to do what I have in mind: doing a Token Exchange of the access token originating from the OIDC login. The issue lies in the ability to read the value of the access token from the TokenExchangeOAuth2AuthorizedClientProvider's subject token resolver. My understanding is that this access token can only be retrieved using authorizedClientRepository.loadAuthorizedClient(clientRegistrationId, principal, request).getAccessToken(), right?

So that's what I came up with:

// Clone of org.springframework.security.oauth2.web.ContextAttributesMapper, with this only difference that
// it puts the `HttpServletRequest` object in the context attributes.
public class CustomContextAttributesMapper implements Function<OAuth2AuthorizeRequest, Map<String, Object>> {

    public static final String REQUEST_ATTRIBUTE_NAME = OAuth2AuthorizationContext.class.getName()
            .concat(".REQUEST");

    @Override
    public Map<String, Object> apply(OAuth2AuthorizeRequest authorizeRequest) {
        Map<String, Object> contextAttributes = new HashMap<>();
        HttpServletRequest servletRequest = getHttpServletRequestOrDefault(authorizeRequest.getAttributes());
        contextAttributes.put(REQUEST_ATTRIBUTE_NAME, servletRequest);
        String scope = servletRequest.getParameter(OAuth2ParameterNames.SCOPE);
        if (StringUtils.hasText(scope)) {
            contextAttributes.put(OAuth2AuthorizationContext.REQUEST_SCOPE_ATTRIBUTE_NAME,
                    StringUtils.delimitedListToStringArray(scope, " "));
        }
        return contextAttributes;
    }

Thanks to this custom mapper, it's possible to use the original Servlet request to get hold of the access token:

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    // ...
    @Bean
    public ApplicationRunner runner(OAuth2AuthorizedClientManager oauth2AuthorizedClientManager) {
        return args -> {
            if (oauth2AuthorizedClientManager instanceof DefaultOAuth2AuthorizedClientManager defaultOAuth2AuthorizedClientManager) {
                defaultOAuth2AuthorizedClientManager.setContextAttributesMapper(new CustomContextAttributesMapper());
            }
        };
    }

    @Bean
    public OAuth2AuthorizedClientProvider tokenExchangeOAuth2AuthorizedClientProvider(OAuth2AuthorizedClientRepository authorizedClientRepository) {
        TokenExchangeOAuth2AuthorizedClientProvider authorizedClientProvider = new TokenExchangeOAuth2AuthorizedClientProvider();

        // ...
        authorizedClientProvider.setSubjectTokenResolver(context -> {
            if (context.getPrincipal() instanceof OAuth2AuthenticationToken oauth2AuthenticationToken) {
                HttpServletRequest httpServletRequest = context.getAttribute(CustomContextAttributesMapper.REQUEST_ATTRIBUTE_NAME);
                if (httpServletRequest == null) {
                    return null;
                }
                OAuth2AuthorizedClient oauth2AuthorizedClient = authorizedClientRepository.loadAuthorizedClient(
                        oauth2AuthenticationToken.getAuthorizedClientRegistrationId(),
                        context.getPrincipal(), httpServletRequest);
                return oauth2AuthorizedClient.getAccessToken();
            }
            return null;
        });
        // ...

        return authorizedClientProvider;
    }

    @Bean
    public WebClient myApp2Client(OAuth2AuthorizedClientManager authorizedClientManager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2FilterFunction =
                new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        oauth2FilterFunction.setDefaultOAuth2AuthorizedClient(true);
        oauth2FilterFunction.setDefaultClientRegistrationId("my-app-2");
        return WebClient.builder()
                .apply(oauth2FilterFunction.oauth2Configuration())
                .build();
    }

}

Does that make sense? Or was there an easier way?

Would you consider adding contextAttributes.put(REQUEST_ATTRIBUTE_NAME, servletRequest); or something similar to the default ContextAttributesMapper implementation?

Thanks a lot!

Comment From: sjohnr

Thanks for the issue, @dalbani. I'm glad you are excited about token exchange support!

My understanding is that this access token can only be retrieved using authorizedClientRepository.loadAuthorizedClient(clientRegistrationId, principal, request).getAccessToken(), right?

That would be one way to do it, yes. The intended way I had in mind would be to use OAuth2AuthorizedClientManager which already can access the current request and also automatically handles refreshing the token. Something like this:

@Bean
public OAuth2AuthorizedClientProvider tokenExchange(
        ClientRegistrationRepository clientRegistrationRepository,
        OAuth2AuthorizedClientRepository authorizedClientRepository) {

    // This OAuth2AuthorizedClientManager is used for resolving the current
    // user's access token.
    OAuth2AuthorizedClientManager authorizedClientManager =
            new DefaultOAuth2AuthorizedClientManager(
                    clientRegistrationRepository, authorizedClientRepository);
    Function<OAuth2AuthorizationContext, OAuth2Token> subjectResolver = (context) -> {
        if (context.getPrincipal() instanceof OAuth2AuthenticationToken oauthAuthenticationToken) {
            // Get the current user's client registration id
            String clientRegistrationId = oauthAuthenticationToken.getAuthorizedClientRegistrationId();

            OAuth2AuthorizeRequest authorizeRequest =
                    OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId)
                            .principal(context.getPrincipal())
                            .build();
            OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
            Assert.notNull(authorizedClient, "authorizedClient cannot be null");

            return authorizedClient.getAccessToken();
        }

        return null; // This should probably throw an exception
    };

    TokenExchangeOAuth2AuthorizedClientProvider authorizedClientProvider =
            new TokenExchangeOAuth2AuthorizedClientProvider();
    authorizedClientProvider.setSubjectTokenResolver(subjectResolver);

    return authorizedClientProvider;
}

I believe that should work and doesn't require customizing the context attributes mapper. If that doesn't work for you, let me know and we can consider whether it makes sense to add context attributes.

Comment From: dalbani

Thanks @sjohnr for your suggestion. I've just tried it, but unless I did something wrong, I got an exception at https://github.com/spring-projects/spring-security/blob/main/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizedClientManager.java#L144.

java.lang.IllegalArgumentException: servletRequest cannot be null
...
Original Stack Trace:
        at org.springframework.util.Assert.notNull(Assert.java:172) ~[spring-core-6.1.10.jar:6.1.10]
        at org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager.authorize(DefaultOAuth2AuthorizedClientManager.java:144) ~[spring-security-oauth2-client-6.3.1.jar:6.3.1]
        at net.ripe.oidc.config.WebSecurityConfig.lambda$tokenExchangeOAuth2AuthorizedClientProvider$2(WebSecurityConfig.java:105) ~[classes/:na]
        at org.springframework.security.oauth2.client.TokenExchangeOAuth2AuthorizedClientProvider.authorize(TokenExchangeOAuth2AuthorizedClientProvider.java:82) ~[spring-security-oauth2-client-6.3.1.jar:6.3.1]
        at org.springframework.security.oauth2.client.DelegatingOAuth2AuthorizedClientProvider.authorize(DelegatingOAuth2AuthorizedClientProvider.java:71) ~[spring-security-oauth2-client-6.3.1.jar:6.3.1]
        at org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager.authorize(DefaultOAuth2AuthorizedClientManager.java:176) ~[spring-security-oauth2-client-6.3.1.jar:6.3.1]
        at org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.lambda$authorizeClient$22(ServletOAuth2AuthorizedClientExchangeFilterFunction.java:486) ~[spring-security-oauth2-client-6.3.1.jar:6.3.1]
...

Comment From: sjohnr

I've just tried it, but unless I did something wrong, I got an exception

Sorry that it is not working for you. At this point, it would be very helpful if you could provide a minimal, reproducible sample so I can investigate further. I can set up my own Keycloak instance, but it would be most helpful if you could provide in your sample any Spring applications (OAuth2 clients and resource servers) involved in the token exchange flow.

Comment From: spring-projects-issues

If you would like us to look at this issue, please provide the requested information. If the information is not provided within the next 7 days this issue will be closed.

Comment From: dalbani

I’m currently on holiday so the code will be provided in a couple of weeks.

Comment From: dalbani

I've create a test case in https://github.com/dalbani/spring-security-test-case. To run it, it's only a matter of doing docker compose up first, starting the application up and going to http://localhost:8080 (username/password user@example.com). The same issue I mentioned above will then be thrown.

Comment From: sjohnr

Thanks @dalbani. Unfortunately, when I run docker compose up I receive an error while downloading keycloak-config:

keycloak-config Error Get "https://docker-registry.ripe.net/v2/": net/http: request canceled while waiting for connection (Client....                       15.1s 
 ⠙ keycloak [⣿]   426MB / 429.6MB Pulling                                                                                                                      15.1s 
   ⠹ 4a8896b04aec Extracting [============================================>      ]  381.6MB/429.6MB                                                            14.3s 
Error response from daemon: Get "https://docker-registry.ripe.net/v2/": net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)

Comment From: dalbani

My bad, @sjohnr, I copy/paste code from work, which is not relevant here. I fixed it in the aforementioned GitHub repository.

Comment From: sjohnr

Thanks for fixing that @dalbani. I was still not able to quite get the sample running with spring-boot-starter-docker-compose but when removing that and running docker myself, I was able to get the app started.

In terms of reproducing the issue, the code provided was not quite minimal. I was able to reproduce the issue with the following minimal config (based on yours):

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public OAuth2AuthorizedClientProvider alternateTokenExchangeOAuth2AuthorizedClientProvider(
            ClientRegistrationRepository clientRegistrationRepository,
            OAuth2AuthorizedClientRepository authorizedClientRepository) {

        // This OAuth2AuthorizedClientManager is used for resolving the current
        // user's access token.
        OAuth2AuthorizedClientManager authorizedClientManager =
                new DefaultOAuth2AuthorizedClientManager(
                        clientRegistrationRepository, authorizedClientRepository);
        Function<OAuth2AuthorizationContext, OAuth2Token> subjectResolver = (context) -> {
            if (context.getPrincipal() instanceof OAuth2AuthenticationToken oauthAuthenticationToken) {
                // Get the current user's client registration id
                String clientRegistrationId = oauthAuthenticationToken.getAuthorizedClientRegistrationId();

                OAuth2AuthorizeRequest authorizeRequest =
                        OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId)
                                .principal(context.getPrincipal())
                                .build();
                OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
                Assert.notNull(authorizedClient, "authorizedClient cannot be null");

                return authorizedClient.getAccessToken();
            }

            return null; // This should probably throw an exception
        };

        TokenExchangeOAuth2AuthorizedClientProvider authorizedClientProvider =
                new TokenExchangeOAuth2AuthorizedClientProvider();
        authorizedClientProvider.setSubjectTokenResolver(subjectResolver);

        return authorizedClientProvider;
    }

    @Bean
    public WebClient httpBinWebClient(OAuth2AuthorizedClientManager authorizedClientManager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2FilterFunction =
                new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        return WebClient.builder()
                .baseUrl("https://httpbin.org/get")
                .apply(oauth2FilterFunction.oauth2Configuration())
                .build();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests((authorize) -> authorize
                        .requestMatchers("/favicon.ico").permitAll()
                        .anyRequest().authenticated()
                )
                .oauth2Login(withDefaults())
                .build();
    }

}

I may have missed this issue as it does not occur in a reactive application, and also does not occur with the default subjectTokenResolver in a servlet application. In any case, thank you for finding this and helping reproduce it!

I think we can consider adding attributes to the DefaultContextAttributesMapper to solve this. Would you be interested in submitting a PR? I'd be happy to work with you to get it merged.

Comment From: sjohnr

Apologies @dalbani, upon taking another look at this, I do not believe adding context attributes will solve the problem.

Instead, I have determined that the best way to accomplish this is to use the alternate implementation of OAuth2AuthorizedClientManager (which is AuthorizedClientServiceOAuth2AuthorizedClientManager) to obtain the current user's access token. This implementation does not need to have access to the current request to resolve the access token.

So the configuration would change to this:

@Bean
public OAuth2AuthorizedClientProvider tokenExchange(
        ClientRegistrationRepository clientRegistrationRepository,
        OAuth2AuthorizedClientService authorizedClientService) {

    // This OAuth2AuthorizedClientManager is used for resolving the current
    // user's access token.
    OAuth2AuthorizedClientManager authorizedClientManager =
        new AuthorizedClientServiceOAuth2AuthorizedClientManager( // <- this implementation does not require the request
            clientRegistrationRepository, authorizedClientService);
    Function<OAuth2AuthorizationContext, OAuth2Token> subjectResolver = (context) -> {
        if (context.getPrincipal() instanceof OAuth2AuthenticationToken principal) {
            // Get the current user's client registration id
            String clientRegistrationId = principal.getAuthorizedClientRegistrationId();

            OAuth2AuthorizeRequest authorizeRequest =
                OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId)
                    .principal(principal)
                    .build();
            OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
            Assert.notNull(authorizedClient, "authorizedClient cannot be null");

            return authorizedClient.getAccessToken();
        }

        return null; // This should probably throw an exception
    };

    TokenExchangeOAuth2AuthorizedClientProvider authorizedClientProvider =
        new TokenExchangeOAuth2AuthorizedClientProvider();
    authorizedClientProvider.setSubjectTokenResolver(subjectResolver);

    return authorizedClientProvider;
}

No additional workarounds or context attributes are required. Please also note that I have recently added support for using RestClient to make protected resources requests (see gh-15437) which does not require the alternate implementation above since it operates on the same thread as the original request.

I'm going to close this issue with the above configuration as the accepted solution. Please let me know if it does not work for you in your real application and we can evaluate further.

Comment From: dalbani

Thanks a lot for your work, I'll have a look at your solution!

Comment From: dalbani

I'm happy to report that I was successful at using RestClient and its new support for OAuth. In case it may be of interest to others, I put the relevant bits below:

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: https://idp.example.com/realms/my-realm
        registration:
          keycloak:
            provider: keycloak
            client-id: my-app
            client-secret: my-app
            authorization-grant-type: authorization_code
            scope:
              - openid
              - profile
              - email
              - 3rd-party-resource.read
          3rd-party-service:
            provider: keycloak
            client-id: my-app
            client-secret: my-app
            authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange
            scope:
              - 3rd-party-resource.read
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    private final ClientRegistrationRepository clientRegistrationRepository;

    public WebSecurityConfig(ClientRegistrationRepository clientRegistrationRepository) {
        this.clientRegistrationRepository = clientRegistrationRepository;
    }

    @Bean
    public OAuth2AuthorizedClientRepository authorizedClientRepository() {
        return new HttpSessionOAuth2AuthorizedClientRepository();
    }

    @Bean
    public OAuth2AuthorizedClientProvider tokenExchangeOAuth2AuthorizedClientProvider(
            @Lazy OAuth2AuthorizedClientManager authorizedClientManager) {
        RestClientTokenExchangeTokenResponseClient tokenResponseClient = new RestClientTokenExchangeTokenResponseClient();
        tokenResponseClient.addParametersConverter(grantRequest -> {
            MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
            parameters.add(OAuth2ParameterNames.AUDIENCE, grantRequest.getClientRegistration().getRegistrationId());
            return parameters;
        });

        TokenExchangeOAuth2AuthorizedClientProvider authorizedClientProvider = new TokenExchangeOAuth2AuthorizedClientProvider();

        authorizedClientProvider.setAccessTokenResponseClient(tokenResponseClient);
        authorizedClientProvider.setSubjectTokenResolver(context -> {
            if (context.getPrincipal() instanceof OAuth2AuthenticationToken oauth2AuthenticationToken) {
                String clientRegistrationId = oauth2AuthenticationToken.getAuthorizedClientRegistrationId();

                OAuth2AuthorizeRequest authorizeRequest =
                        OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId)
                                .principal(oauth2AuthenticationToken)
                                .build();

                OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
                Assert.notNull(authorizedClient, "authorizedClient cannot be null");

                return authorizedClient.getAccessToken();
            }
            return null;
        });

        return authorizedClientProvider;
    }

    @Bean
    public RestClient thirdPartyServiceRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
        OAuth2ClientHttpRequestInterceptor requestInterceptor =
                new OAuth2ClientHttpRequestInterceptor(authorizedClientManager, request -> "3rd-party-service");

        return RestClient.builder()
                .baseUrl("https://api.3rdparty.com")
                .requestInterceptor(requestInterceptor)
                .build();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, ServerProperties serverProperties) throws Exception {
        return http
                .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
                        .requestMatchers("/favicon.ico").permitAll()
                        .anyRequest().authenticated())
                .oauth2Login(withDefaults())
                .build();
    }

}

For some reason, it looks like I had to tweak the order of the bean initialisation, with the use of @Lazy as in:

    @Bean
    public OAuth2AuthorizedClientProvider tokenExchangeOAuth2AuthorizedClientProvider(
            @Lazy OAuth2AuthorizedClientManager authorizedClientManager) {

Otherwise this tokenExchangeOAuth2AuthorizedClientProvider bean apparently did not end up in the so-called "additional AuthorizedClientProviders" in OAuth2ClientConfiguration. And thus wasn't present in the list of providers in DelegatingOAuth2AuthorizedClientProvider. Or did I miss something obvious?

Comment From: sjohnr

@dalbani I'm glad you are making progress!

For some reason, it looks like I had to tweak the order of the bean initialisation, with the use of @Lazy

Your configuration is unfortunately not correct because you are creating a cyclical dependency between the injected OAuth2AuthorizedClientManager and the published TokenExchangeOAuth2AuthorizedClientProvider. Please take a look at my configuration example above and adjust accordingly. Notice in particular that I'm creating a separate instance of OAuth2AuthorizedClientManager and not injecting one, so that no cyclical dependency is formed.

Comment From: dalbani

As I referred to in my previous message, the tokenExchangeOAuth2AuthorizedClientProvider bean is automatically picked up and added to the DelegatingOAuth2AuthorizedClientProvider only when the auto-configuration "magic" creates the DefaultOAuth2AuthorizedClientManager instance and calls setAuthorizedClientProvider() with this instance of DelegatingOAuth2AuthorizedClientProvider. If I declare my own instance of OAuth2AuthorizedClientManager as you suggested, no token exchange takes place at all as my bean is never wired/used in the OAuth setup.

I'm curious if you could share some test case showing the full solution you have in mind, because I couldn't get it to work with the snippets that you provided. Or is there a test in the Spring Security codebase that covers this scenario?

Comment From: sjohnr

As I referred to in my previous message, the tokenExchangeOAuth2AuthorizedClientProvider bean is automatically picked up and added to the DelegatingOAuth2AuthorizedClientProvider only when the auto-configuration "magic" creates the DefaultOAuth2AuthorizedClientManager instance and calls setAuthorizedClientProvider() with this instance of DelegatingOAuth2AuthorizedClientProvider.

This is actually performed by the OAuth2AuthorizedClientManagerRegistrar which is a BeanDefinitionRegistryPostProcessor that composes the DelegatingOAuth2AuthorizedClientProvider and registers a bean of DefaultOAuth2AuthorizedClientManager if one does not exist. This is why I mentioned that you should not inject the OAuth2AuthorizedClientManager bean into the tokenExchangeOAuth2AuthorizedClientProvider() method since it creates a cyclic dependency between your bean and the one provided by the framework. Using @Lazy wouldn't be the correct solution in this case.

I'm curious if you could share some test case showing the full solution you have in mind, because I couldn't get it to work with the snippets that you provided.

You can check out the config from this sample from SpringOne, which is using the reactive version of the components.

The servlet version is exactly the same with different class names. You only need two beans, which are SecurityFilterChain and TokenExchangeOAuth2AuthorizedClientProvider. If you are also publishing your own OAuth2AuthorizedClientManager then the one provided by the framework won't be published and things might not work.

Comment From: dalbani

Thanks a lot for your explanation. I followed your suggestion and instantiated a DefaultOAuth2AuthorizedClientManager as in:

    @Bean
    public OAuth2AuthorizedClientProvider tokenExchangeOAuth2AuthorizedClientProvider(
            OAuth2AuthorizedClientRepository authorizedClientRepository) {
        RestClientTokenExchangeTokenResponseClient tokenResponseClient = new RestClientTokenExchangeTokenResponseClient();
        tokenResponseClient.addParametersConverter(grantRequest -> {
            MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
            parameters.add(OAuth2ParameterNames.AUDIENCE, grantRequest.getClientRegistration().getRegistrationId());
            return parameters;
        });

        DefaultOAuth2AuthorizedClientManager authorizedClientManager =
                new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository);

        TokenExchangeOAuth2AuthorizedClientProvider authorizedClientProvider = new TokenExchangeOAuth2AuthorizedClientProvider();

        authorizedClientProvider.setAccessTokenResponseClient(tokenResponseClient);
        authorizedClientProvider.setSubjectTokenResolver(context -> {
            if (context.getPrincipal() instanceof OAuth2AuthenticationToken oauth2AuthenticationToken) {
                String clientRegistrationId = oauth2AuthenticationToken.getAuthorizedClientRegistrationId();

                OAuth2AuthorizeRequest authorizeRequest =
                        OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId)
                                .principal(oauth2AuthenticationToken)
                                .build();

                OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
                Assert.notNull(authorizedClient, "authorizedClient cannot be null");

                return authorizedClient.getAccessToken();
            }
            return null;
        });

        return authorizedClientProvider;
    }

And token exchange works fine now 👍