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 AuthorizedClientProvider
s" 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 theDelegatingOAuth2AuthorizedClientProvider
only when the auto-configuration "magic" creates theDefaultOAuth2AuthorizedClientManager
instance and callssetAuthorizedClientProvider()
with this instance ofDelegatingOAuth2AuthorizedClientProvider
.
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 👍