DEV Community

Cover image for Seamless Migration to Keycloak: Token Exchange
Mohammed Ammer
Mohammed Ammer

Posted on

Seamless Migration to Keycloak: Token Exchange

In previous post, 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 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.

public class OauthServiceIdentityProvider extends AbstractOAuth2IdentityProvider<OauthServiceIdentityProviderConfig>
{ 
...
   @Override
   protected boolean supportsExternalExchange()
   {
      return true;
   }
...
}
Enter fullscreen mode Exit fullscreen mode

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, 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)

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

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.

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

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, I explained it in details to keep your platform Terraformed ;-)

I hope you find it useful.

Top comments (0)