OAuth 2 Token Exchange with Spring Security and Keycloak

Yuan Ji - Sep 14 - - Dev Community

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

  1. Create a new realm myhealth-demo for the MyHealth keycloak server.
  2. Under the myhealth-demo realm, create a new client myhealth-ui for MyHealth's frontend. Add the client role view-health-record. Since it is a SPA web application, disable Client authentication.
  3. Add a user John Doe, login id john, password john with client role view-health-record.
  4. Create a new client mydoctor-api-server for the MyDoctor backend API server to access MyHealth API endpoints.
  5. Create another client mydoctor-auth for MyDoctor keycloak server to link MyHealth keycloak server as an identity provider.

Setting up Keycloak for MyDoctor

  1. Create a new realm mydoctor-demo for the MyDoctor keycloak server.
  2. Under the mydoctor-demo realm, create a new client mydoctor-ui for MyDoctor's frontend. Add client role edit-appointment and view-appointment. Disable Client authentication.
  3. Add a user doctor with the client role edit-appointment.
  4. Add another client mydoctor-api for the backend API server to do token exchange requests.
  5. In Realm settings, select User registration tab, click button Assign role to add role view-appointment, so any new user will automatically have this role.

Adding MyHealth as an Identity Provider:

  1. In the MyDoctor keycloak server under mydoctor-demo realm, navigate to Identity Providers and select OpenID Connect.
  2. 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.
  3. Enter client ID and secret of mydoctor-auth client from MyHealth keycloak server.
  4. Turn on Store tokens.
  5. 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'
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
Enter fullscreen mode Exit fullscreen mode

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,
MyDoctor Website

and click Login button, to redirect to MyDoctor Keycloak Login Page:
MyDoctor Keycloak Login

Then click MyHealth Keycloak button to login with MyHealth Keycloak server:
MyHealth Keycloak Login

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.
MyDoctor Keycloak User

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.
MyDoctor UI

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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.

. .
Terabox Video Player