Seamless Migration to Keycloak: Token Exchange
Tags: keycloak, tokenexchange
Published: 2024-03-31 22:27:13

In [previous post](https://dev.to/mohammedalics/seamless-migration-to-keycloak-refresh-token-4m4f), we spoke about the migration of Refresh Token, where Token Exchange is playing a big role to have a seamless migration. 

Keycloak is providing token exchange just by configuration, however, in our use case, we use [custom identity provider](https://dev.to/mohammedalics/seamless-migration-to-keycloak-non-oidc-oauth-20-broker-58a6) due to our lack support to OpenID Connect. 

---
In this article, I'll discuss an extension to our custom identity provider (OAuth 2.0) to support `token exchange`. 

## Enable external exchange
In our `OauthServiceIdentityProvider`, we override the `supportsExternalExchange` to return `true`. 

```java
public class OauthServiceIdentityProvider extends AbstractOAuth2IdentityProvider<OauthServiceIdentityProviderConfig>
{ 
...
   @Override
   protected boolean supportsExternalExchange()
   {
      return true;
   }
...
}
```

## Exchange implementation
To implement the exchange logic, override the method `exchangeExternalImpl`. 

In the below example, I added some validations then called  the `doGetFederatedIdentity(..)` to get the federation. Different than the doGetFederatedIdentity in [Brokering non-OIDC OAuth 2.0 identities](https://dev.to/mohammedalics/seamless-migration-to-keycloak-non-oidc-oauth-20-broker-58a6), I use `useSessionContent` to decide from where we get the `clientId`. In case of token exchange, we get the clientId from the token claim `token.getOtherClaims().get(TOKEN_CLAIM_CLIENT_ID)`

```java
@Override
protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap < String, String > params) {
    final String subjectToken = params.getFirst(OAuth2Constants.SUBJECT_TOKEN);
    if (subjectToken == null) {
        event.detail(Details.REASON, OAuth2Constants.SUBJECT_TOKEN + " param unset");
        event.error(Errors.INVALID_TOKEN);
        throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "token not set", Response.Status.BAD_REQUEST);
    }
    String subjectTokenType = params.getFirst(OAuth2Constants.SUBJECT_TOKEN_TYPE);
    if (subjectTokenType == null) {
        subjectTokenType = OAuth2Constants.REFRESH_TOKEN_TYPE;
    }
    if (!OAuth2Constants.REFRESH_TOKEN_TYPE.equals(subjectTokenType)) {
        event.detail(Details.REASON, OAuth2Constants.SUBJECT_TOKEN_TYPE + " invalid");
        event.error(Errors.INVALID_TOKEN_TYPE);
        throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token type", Response.Status.BAD_REQUEST);
    }

    final String requestedType = params.getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE);
    if (requestedType != null && !requestedType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE)) {
        event.detail(Details.REASON, "requested_token_type unsupported");
        event.error(Errors.INVALID_REQUEST);
        throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "invalid requested token type", Response.Status.BAD_REQUEST);
    }

    return doGetFederatedIdentity(subjectToken, false);

}

private BrokeredIdentityContext doGetFederatedIdentity(String accessToken, boolean useSessionContent) {
    if (accessToken == null) {
        throw new IdentityBrokerException("No token from server.");
    }

    JsonWebToken token;
    try {
        JWSInput jws = new JWSInput(accessToken);
        token = jws.readJsonContent(JsonWebToken.class);
        final String username = token.getSubject();
        String clientId;
        if (useSessionContent) {
            clientId = session.getContext().getClient().getName();
        } else {
            clientId = String.valueOf(token.getOtherClaims().get(TOKEN_CLAIM_CLIENT_ID));

        }

        final String userId = String.valueOf(token.getOtherClaims().get(TOKEN_CLAIM_USER_ID));
        final String userType = String.valueOf(token.getOtherClaims().get(TOKEN_CLAIM_USER_TYPE));

        final BrokeredIdentityContext user = new BrokeredIdentityContext(userId);
        user.setUsername(username);
        user.setIdpConfig(getConfig());
        user.setIdp(this);
        user.setUserAttribute(TOKEN_CLAIM_USER_ID, userId);
        user.getContextData().put(TOKEN_CLAIM_USER_TYPE, userType);

        return user;
    } catch (JWSInputException e) {
        throw new IdentityBrokerException("Invalid token", e);
    }
}
```

## Add user attributes and mappings claims 
Same as the `authenticationFinished` method, for the token exchange, we need to override the `exchangeExternalComplete` to enrich our user session with whatever needed add the required user attributes and session notes. Session notes can then be used later to map Keycloak token claims.

```java
@Override
public void exchangeExternalComplete(UserSessionModel userSession, BrokeredIdentityContext context, MultivaluedMap < String, String > params) {
    final String value = (String) context.getContextData().get(TOKEN_CLAIM_USER_TYPE);
    if (StringUtil.isNotBlank(value)) {
        userSession.setNote(field, value);
    }

    String userId = context.getUserAttribute(TOKEN_CLAIM_USER_ID);
    if (userId != null && !userId.isBlank()) {
        userSession.getUser().setSingleAttribute(TOKEN_CLAIM_USER_ID, userId);
    } else {
        logger.warn("userId is null or blank");
    }
    super.exchangeExternalComplete(userSession, context, params);
}
```
---

By this article, I am done with our custom identity provider implementation to fully support OAuth 2.0 including token exchange. 

Are you using Terraform to configure your Keycloak cluster? well, it is not easy to make it with Terraform when speaking about custom identity provider. In [Custom Identity Providers in Keycloak with Terraform](https://dev.to/mohammedalics/custom-identity-providers-in-keycloak-with-terraform-a-step-by-step-guide-81h), I explained it in details to keep your platform Terraformed ;-)

I hope you find it useful.