OAuth 2 Token Exchange with Spring Security and Keycloak
Yuan Ji
Posted on September 14, 2024
Introduction
In today's interconnected digital landscape, companies often collaborate to provide seamless services to their users. In this post, we’ll explore a scenario involving two hypothetical companies: MyDoctor and MyHealth. We’ll demonstrate how MyHealth users can log in to MyDoctor using their MyHealth credentials, and how MyDoctor's backend can securely call MyHealth's APIs on behalf of the user. To achieve this, we’ll leverage OAuth 2 Token Exchange (RFC8693) with Spring Security and Keycloak.
All the code can be found in my GitHub project here.
Business Scenario
Let’s start by outlining the scenario:
MyHealth: A service where users can view their health records.
MyDoctor: A service where users can register, log in, and talk to AI doctors to get medical advice.
Now, imagine MyHealth users want to access MyDoctor without creating a new account. Additionally, for MyDoctor's AI bot to give better advice, the backend needs to securely access MyHealth's APIs to retrieve user health records or other user-specific data.
In the past, this could have been done using API key, where MyHealth would assign a unique secret key to MyDoctor for accessing MyHealth API. However, this approach only authenticates the application, meaning MyDoctor would have full access to all of MyHealth’s data, which raises trust and security concerns.
To avoid this, MyHealth needs to authenticate the actual user and apply proper access controls. So, how can MyDoctor impersonate the current logged-in user and access MyHealth’s APIs? We can use OAuth 2’s Token Exchange extension, which allows one token (e.g., from MyDoctor) to be exchanged for another token (e.g., from MyHealth) that has the correct permissions for that user.
Understanding OAuth 2 Token Exchange
Token Exchange RFC 8693 is an extension to the standard OAuth 2.0 that allows a client to exchange an existing token for another token. This is particularly useful in scenarios where:
- A client needs to access resources on behalf of the user from multiple APIs.
- There is a need to exchange one token (e.g., an ID token or access token) for another token (e.g., an access token with specific permissions or scope).
In this scenario:
- A MyHealth user logs into MyDoctor via Keycloak’s identity provider linking.
- The MyDoctor backend exchanges the received token for one that allows it to access MyHealth's APIs.
Keycloak Setup and Linking Identity Providers
To enable MyHealth users to log in to MyDoctor via their MyHealth account, we need to configure Keycloak to act as the identity provider (IdP). Since Token Exchange is a preview feature, you have to start the Keycloak server with --features=preview
.
Setting up Keycloak for MyHealth
- Create a new realm
myhealth-demo
for the MyHealth keycloak server. - Under the
myhealth-demo
realm, create a new clientmyhealth-ui
for MyHealth's frontend. Add the client roleview-health-record
. Since it is a SPA web application, disableClient authentication
. - Add a user John Doe, login id
john
, passwordjohn
with client roleview-health-record
. - Create a new client
mydoctor-api-server
for the MyDoctor backend API server to access MyHealth API endpoints. - Create another client
mydoctor-auth
for MyDoctor keycloak server to link MyHealth keycloak server as an identity provider.
Setting up Keycloak for MyDoctor
- Create a new realm
mydoctor-demo
for the MyDoctor keycloak server. - Under the
mydoctor-demo
realm, create a new clientmydoctor-ui
for MyDoctor's frontend. Add client roleedit-appointment
andview-appointment
. DisableClient authentication
. - Add a user
doctor
with the client roleedit-appointment
. - Add another client
mydoctor-api
for the backend API server to do token exchange requests. - In
Realm settings
, selectUser registration
tab, click buttonAssign role
to add roleview-appointment
, so any new user will automatically have this role.
Adding MyHealth as an Identity Provider:
- In the MyDoctor keycloak server under
mydoctor-demo
realm, navigate to Identity Providers and select OpenID Connect. - Enter the details of the MyHealth Keycloak server, using the well-known OpenID configuration URL
http://auth.myhealth:8090/realms/myhealth-demo/.well-known/openid-configuration
. - Enter client ID and secret of
mydoctor-auth
client from MyHealth keycloak server. - Turn on
Store tokens
. - Follow the Keycloak documentation 7.3. Internal token to external token exchange, to enable permissions for token exchange, and create a client policy.
It is very tedious to setup Keycloak servers correctly, however, you can find the step-by-step instructions in my Github project to start two Keycloak servers with docker and take a look at all the settings. Just login to Keycloak servers using admin
as username and password at http://mydoctor:8080 and http://myhealth:8090.
Implementing OAuth 2 Token Exchange in Spring Security
Token Exchange has been supported in Spring Security since version 6.3. Below, we will demonstrate how MyDoctor’s backend can use this feature to retrieve the health records of a logged-in MyHealth user.
Configure MyHealth API Server App:
The MyHealth backend API myhealth-api
is an OAuth 2 resource server. Add the following dependency in build.gradle
:
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
Config spring security in ProjectConfig
:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class ProjectConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.csrf(AbstractHttpConfigurer::disable)
.cors((cors) -> cors.configurationSource(request -> {
var corsConfig = new CorsConfiguration();
corsConfig.setAllowedOrigins(List.of("http://myhealth:4210"));
corsConfig.setAllowedMethods(
List.of("GET", "POST", "OPTIONS", "PUT", "DELETE")
);
corsConfig.setAllowedHeaders(List.of("*"));
corsConfig.setAllowCredentials(true);
return corsConfig;
}))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/records").hasAuthority("ROLE_view_health_record")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer
.jwt( jwt -> jwt.jwtAuthenticationConverter(new KeycloakJwtAuthenticationConverter(List.of("myhealth-ui", "mydoctor-api-server"))))
)
;
// @formatter:on
return http.build();
}
@Bean
public JwtDecoder jwtDecoder(OAuth2ResourceServerProperties oAuth2ResourceServerProperties) {
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(oAuth2ResourceServerProperties.getJwt().getJwkSetUri()).build();
jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(oAuth2ResourceServerProperties.getJwt().getIssuerUri()));
return jwtDecoder;
}
}
The KeycloakJwtAuthenticationConverter
is the Spring Converter to translate OAuth 2 access token claims of resource_access
to Spring Security GrantedAuthority
. I copied the code from StackOverflow discussion How configure the JwtAuthenticationConverter for a specific claim structure?
And here is the config for resource server in application.yml:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://auth.myhealth:8090/realms/myhealth-demo
jwk-set-uri: http://auth.myhealth:8090/realms/myhealth-demo/protocol/openid-connect/certs
Configure MyDoctor API Server App:
MyDoctor API backend needs to call MyHealth API, so it acts as both an OAuth 2 Resource Server and an OAuth 2 Client. Add these dependencies inbuild.gradle
:
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
In application.yml, configure the two clients myhealth-client
and mydoctor-client
:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://auth.mydoctor:8080/realms/mydoctor-demo
jwk-set-uri: http://auth.mydoctor:8080/realms/mydoctor-demo/protocol/openid-connect/certs
client:
registration:
myhealth-client:
client-id: mydoctor-api-server
authorization-grant-type: urn:ietf:params:oauth:grant-type:jwt-bearer
provider: health-auth-provider
mydoctor-client:
client-id: mydoctor-api
client-secret: nvYxjQFYGdNI8zj5Nb3Jz25ezWgN1cE8
client-authentication-method: client_secret_basic
authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange
provider: doctor-auth-provider
provider:
doctor-auth-provider:
token-uri: http://auth.mydoctor:8080/realms/mydoctor-demo/protocol/openid-connect/token
health-auth-provider:
token-uri: http://auth.myhealth:8090/realms/myhealth-demo/protocol/openid-connect/token
You can see those two clients have two different providers.
When MyDoctor UI calls backend API endpoint of http://api.mydoctor:8081/api/records
, backend API server actually calls MyHealth API endpoint http://api.myhealth:8082/api/record
using WebClient:
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
@Slf4j
public class RecordController {
private final WebClient webClient;
@GetMapping("/records")
public List<Message> getHealthRecords(
@RegisteredOAuth2AuthorizedClient("mydoctor-client")
OAuth2AuthorizedClient doctorAuthClient
) {
String token = doctorAuthClient.getAccessToken().getTokenValue();
log.debug("Exchanged access token is:\n {}\n", token);
var result = webClient.get()
.uri("http://api.myhealth:8082/api/records")
.headers((headers) -> headers.setBearerAuth(token))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<List<Message>>() {})
.block()
;
log.debug("Return result from MyHealth API Server: {}", result);
return result;
}
}
We have to impersonate current user as MyHealth user, so we add bearer token to WebClient http request header. The token is coming from injected doctorAuthClient
, which is resolved by OAuth2AuthorizedClientArgumentResolver
using client registration ID mydoctor-client
. This client will call MyDoctor keycloak server to do Token Exchange, passing current login user access token from MyDoctor keycloak, and get a new access token from MyHealth keycloak server.
All the tricky parts are in Spring Security configuration:
OAuth2AuthorizedClientProvider tokenExchange(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository
) {
Function<OAuth2AuthorizationContext, OAuth2Token> subjectResolver = (context) -> {
if (context.getPrincipal() instanceof JwtAuthenticationToken jwtAuthenticationToken) {
Jwt jwt = jwtAuthenticationToken.getToken();
OAuth2AccessToken token = new OAuth2AccessToken(TokenType.BEARER, jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt());
log.debug("Get Access Token for current user from JwtAuthenticationToken: {}", token.getTokenValue());
return token;
}
throw new RuntimeException("Cannot resolve subject token with context principal " + context.getPrincipal() );
};
Converter<TokenExchangeGrantRequest, RequestEntity<?>> requestEntityConverter = new TokenExchangeGrantRequestEntityConverter() {
@Override
protected MultiValueMap<String, String> createParameters(TokenExchangeGrantRequest grantRequest) {
MultiValueMap<String, String> parameters = super.createParameters(grantRequest);
parameters.add("requested_issuer", "myhealth-keycloak-oidc");
return parameters;
}
};
DefaultTokenExchangeTokenResponseClient accessTokenResponseClient = new DefaultTokenExchangeTokenResponseClient();
accessTokenResponseClient.setRequestEntityConverter(requestEntityConverter);
TokenExchangeOAuth2AuthorizedClientProvider authorizedClientProvider =
new TokenExchangeOAuth2AuthorizedClientProvider();
authorizedClientProvider.setSubjectTokenResolver(subjectResolver);
authorizedClientProvider.setAccessTokenResponseClient(accessTokenResponseClient);
return authorizedClientProvider;
}
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientService clientService,
OAuth2AuthorizedClientRepository authorizedClientRepository
) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.refreshToken()
.clientCredentials()
.provider(tokenExchange(clientRegistrationRepository, authorizedClientRepository))
.build();
AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager =
new AuthorizedClientServiceOAuth2AuthorizedClientManager(clientRegistrationRepository, clientService);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
@Bean
WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
oauth2Client.setDefaultClientRegistrationId("myhealth-client");
return WebClient.builder()
.apply(oauth2Client.oauth2Configuration())
.build();
}
It took me several days to get it working! I got the idea from Spring Security project issue discussion, and Keycloak documentation.
First, using subjectResolver
to get JWT token from current security context, because we are using KeycloakJwtAuthenticationConverter
to store JwtAuthenticationToken
object as Principal
. Pass it to TokenExchangeOAuth2AuthorizedClientProvider
, so this client provider can call MyDoctor keycloak server with current login user’s access token.
Second, Keycloak server implements RFC8693 Token Exchange a little differently:
Token exchange in Keycloak is a very loose implementation of the OAuth Token Exchange specification at the IETF. We have extended it a little, ignored some of it, and loosely interpreted other parts of the specification.
The Keycloak token exchange request parameters include requested_issuer
, which is the alias of the ID provider in MyDoctor configuration, the MyHealth Keycloak
, and its alias is myhealth-keycloak-oidc
. Since requested_issuer
is not the standard RFC8693 request parameters, Spring Security doesn’t support it as well. Fortunately I can hack TokenExchangeOAuth2AuthorizedClientProvider
with a customized OAuth2AccessTokenResponseClient
to set requested_issuer
parameter inside TokenExchangeGrantRequestEntityConverter
. See above code in tokenExchange()
.
Third, config OAuth2AuthorizedClientManager
with OAuth2AuthorizedClientProvider
from tokenExchange()
, so our doctorAuthClient
above can call MyDoctor Keycloak server to do token exchange as client ID mydoctor-api
!
Last, build WebClient
with ServletOAuth2AuthorizedClientExchangeFilterFunction
, passing client registration ID myhealth-client
, so this WebClient
will be able to call MyHealth API as client ID mydoctor-api-server
, but we use the access token exchanged from MyDoctor Keycloak server as bearer token.
You can open MyDoctor UI at http://mydoctor:4200
,
and click Login
button, to redirect to MyDoctor Keycloak Login Page:
Then click MyHealth Keycloak
button to login with MyHealth Keycloak server:
You can see the host changed from auth.mydoctor:8080
to auth.myhealth:8090
.
Login with MyHealth user John Doe (username and password as john
), you can see in MyDoctor Keycloak server admin console, under mydoctor-demo
realm, a new user john
was added, and this user has links to another identity provider Myhealth-keycloak-oidc
.
In MyDoctor UI, after login, you can click button call http://api.mydoctor:8081/api/records
, so UI will call MyDoctor backend, and backend server actually will first call MyDoctor Keycloak server to do Token Exchange, then call MyHealth API server to get user health records.
From mydoctor-api
server log, we can find out the access token used by MyDoctor UI is
{
"exp": 1726293365,
"iat": 1726293065,
"auth_time": 1726293065,
"jti": "c01fc98f-f959-42ba-a25b-7e8e1a29dc12",
"iss": "http://auth.mydoctor:8080/realms/mydoctor-demo",
"aud": [
"broker",
"account"
],
"sub": "5adba68d-c9cc-487d-a9e2-62bfae549483",
"typ": "Bearer",
"azp": "mydoctor-ui",
"sid": "40cd230f-a5af-4308-84dc-e5f856bc0a0e",
"acr": "1",
"allowed-origins": [
"http://mydoctor:4200"
],
"realm_access": {
"roles": [
"default-roles-mydoctor-demo",
"offline_access",
"uma_authorization"
]
},
"resource_access": {
"mydoctor-ui": {
"roles": [
"view-appointment"
]
},
"broker": {
"roles": [
"read-token"
]
},
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "openid email profile",
"email_verified": false,
"name": "John Doe",
"preferred_username": "john",
"given_name": "John",
"family_name": "Doe",
"email": "john.doe@demo.com"
}
And after token exchange, the access token used by MyDoctor API server:
{
"exp": 1726293365,
"iat": 1726293065,
"auth_time": 1726293065,
"jti": "6c221b25-9b49-45e1-975a-88448104050d",
"iss": "http://auth.myhealth:8090/realms/myhealth-demo",
"aud": [
"myhealth-ui",
"account"
],
"sub": "1b0d98b6-ea18-4dbd-9d6e-a83e621c07c2",
"typ": "Bearer",
"azp": "mydoctor-auth",
"sid": "531b04de-1ac5-4b04-8125-4c8ca10872bf",
"acr": "1",
"allowed-origins": [
"http://auth.mydoctor:8080/*"
],
"realm_access": {
"roles": [
"default-roles-myhealth-demo",
"offline_access",
"uma_authorization"
]
},
"resource_access": {
"myhealth-ui": {
"roles": [
"view-health-record"
]
},
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "openid profile email",
"email_verified": true,
"name": "John Doe",
"preferred_username": "john",
"given_name": "John",
"family_name": "Doe",
"email": "john.doe@demo.com"
}
Before exchange, the access token has the role of view-appointment
for mydoctor-ui
, and after exchange, it has the role of view-health-record
for myhealth-ui
. Quite amazing!
Conclusion
Recently, I designed and implemented an integration allowing users to log in to Company A using Company B’s account and access Company B’s APIs on the user’s behalf. By combining Keycloak’s identity provider linking and the OAuth 2 Token Exchange protocol, we can provide a seamless experience while maintaining secure API calls between different systems.
However, as of September 2024, Token Exchange is still a preview feature in Keycloak, and Spring Security has only recently added support for it. I hope this guide helps others attempting to implement this functionality in their projects.
Posted on September 14, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.