Logo

dev-resources.site

for different kinds of informations.

OAuth 2 Token Exchange with Spring Security and Keycloak

Published at
9/14/2024
Categories
oauth2
tokenexchange
springsecurity
keycloak
Author
jiwhiz
Author
6 person written this
jiwhiz
open
OAuth 2 Token Exchange with Spring Security and Keycloak

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": "[email protected]"
}


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": "[email protected]"
}


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.

oauth2 Article's
30 articles in total
Favicon
OAuth2 Scopes and Claims: Fine-Grained Access Control
Favicon
Defending OAuth2: Advanced Tactics to Block Replay Attacks
Favicon
Understanding the Differences Between OAuth2 and OpenID Connect (OIDC)
Favicon
Demystifying Social Logins: How OAuth2 Powers Seamless Authentication
Favicon
JWT vs Opaque Tokens: A Comprehensive Guide to Choosing Wisely
Favicon
OAuth2 and PKCE: Enhancing Security for Public Clients
Favicon
OAuth2 Authorization Code Grant Type: A Deep Dive
Favicon
OAuth2 in Action: Real-World Use Cases and Examples
Favicon
Advanced OAuth2: Refresh Tokens and Token Expiration Strategies
Favicon
OAuth2 Grant Types Explained: Which One Should You Use?
Favicon
Implementing OAuth2 for Microservices Authentication
Favicon
OAuth2 Client Credentials Grant Type: When and How to Use It
Favicon
OAuth2 vs. OpenID Connect: Understanding the Differences
Favicon
OAuth2: An In-Depth Overview and How It Works
Favicon
Common OAuth2 Misconceptions: Debunking Myths for a Secure Implementation
Favicon
Access Token or ID Token? Which to Use and Why?
Favicon
RFC 9068: The JWT Profile for OAuth2 Access Tokens — A Standard for Seamless Integration
Favicon
OAuth2 Demystified: An Introduction to Secure Authorization
Favicon
Cheat Sheet: Enabling HTTPS on a Fresh Laravel Sail App with MacOS
Favicon
Open Authorization v2.0 OAuth2 mikro servislar xavfsizligi
Favicon
OAuth 2 Token Exchange with Spring Security and Keycloak
Favicon
How to Secure Apache Superset with OAuth2
Favicon
Open Authorization 2.0 (OAuth2.0) - Authorization Code Grant
Favicon
Build a GPT That Talks to Your Database in One Day
Favicon
OpenID Connect Flows: From Implicit to Authorization Code with PKCE & BFF
Favicon
Client assertion in OAuth 2.0 client authentication
Favicon
Python FastAPI: Integrating OAuth2 Security with the Application's Own Authentication Process
Favicon
Call your Azure AD B2C protected API with authenticated HTTP requests from your JetBrains IDE
Favicon
Implementing SSO in React with GitHub OAuth2
Favicon
Securing Azure Functions with OAuth2 Authentication

Featured ones: