DEV Community

Cover image for Seamless Migration to Keycloak: Non-OIDC OAuth 2.0 Broker
Mohammed Ammer
Mohammed Ammer

Posted on

Seamless Migration to Keycloak: Non-OIDC OAuth 2.0 Broker

Brokering OAuth2.0 is essential to our migration from Spring Security OAuth to Keycloak as Spring Security OAuth doesn't support OpenID Connect by default.

While Keycloak offers an extensive array of integrated brokering functionalities, such as OpenID Connect and SAML v2.0, it lacks support for OAuth 2.0 brokering.

Thanks to Keycloak Service Provider Interfaces (SPI), it is possible to write a custom SPI to broker OAuth2.0.

It worth to mention that, reading the source code of Keycloak and use it as a reference is good complement to the official documentation. For instance, It would be much harder to figure out the required implementation to make the Custom OAuth2.0 SPI work without debugging the OpenID Connect SPI provided by Keycloak.

In this article, I will also describe on how to add keycloak user attributes based on the remote identity token and also map remote identity token to Keycloak token.

Problem with built-in OIDC Provider

The main issue when OIDC is not supported by the remote identity provider is the missing of the idToken. In this case, Keycloak fails to validate it.

Now you expect what is supposed to be considered to have the proper OAuth2.0 SPI. Yes, overcome the idToken checks and all logic around it.

OAuth2.0 SPI

To implement an SPI, you need to implement its Provider Factory and Provider Interfaces. You also need to create a service configuration file.

First let us define our dependencies.

Dependencies

At least, you need below dependencies in your pom.xml file.

<dependency>
    <artifactId>keycloak-server-spi</artifactId>
    <groupId>org.keycloak</groupId>
</dependency>
<dependency>
    <artifactId>keycloak-server-spi-private</artifactId>
    <groupId>org.keycloak</groupId>
</dependency>
<dependency>
    <artifactId>keycloak-services</artifactId>
    <groupId>org.keycloak</groupId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

SPI configuration file

For Keycloak server to identity the custom SPI during the starting up, add a new file named org.keycloak.broker.provider.IdentityProviderFactory under src/main/resources/META-INF/services/ directory with below content.

com.example.keycloak.extensions.broker.oauth2.OauthServiceProviderFactory
Enter fullscreen mode Exit fullscreen mode

Provider Configuration

Mainly, the Identity Provider Config for OAuth2.0 requires the Authorize and Token URLs. For sake of simplicity, I'll add both statically in the config but definitely in production make it configurable.

public class OauthServiceIdentityProviderConfig extends OAuth2IdentityProviderConfig
{

    public static final String OAUTH_AUTHORIZE = "https://domain.com/oauth2/oauth/token";
    public static final String OAUTH_TOKEN = "https://domain.com/oauth2/oauth/token";

    public OauthServiceIdentityProviderConfig(IdentityProviderModel model)
    {
        super(model);
    }


    public OauthServiceIdentityProviderConfig()
    {
    }


    public String getAuthPath()
    {
        return OAUTH_AUTHORIZE;
    }


    public String getTokenPath()
    {
        return OAUTH_TOKEN;
    }
}
Enter fullscreen mode Exit fullscreen mode

OAuth Service Identity Provider

Extend the built-in AbstractOAuth2IdentityProvider to get all defaults from Keycloak OAuth2.0 provider built-in implementation.

public class OauthServiceIdentityProvider extends AbstractOAuth2IdentityProvider<OauthServiceIdentityProviderConfig>
{
    public static final String DEFAULT_SCOPE = "myscope";
    public static final String TOKEN_CLAIM_USER_ID = "userId";
    public static final String TOKEN_CLAIM_USER_TYPE = "userType";


    public OauthServiceIdentityProvider(KeycloakSession session, OauthServiceIdentityProviderConfig config)
    {
        super(session, config);
        config.setAuthorizationUrl(config.getAuthPath());
        config.setTokenUrl(config.getTokenPath());
        config.setClientAuthMethod(CLIENT_SECRET_BASIC);
        String defaultScope = config.getDefaultScope();

        if (defaultScope == null || defaultScope.trim().isEmpty())
        {
            config.setDefaultScope(DEFAULT_SCOPE);
        }
    }

    @Override
    protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken)
    {
        return doGetFederatedIdentity(accessToken, true);
    }


    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();
            final String clientId = session.getContext().getClient().getName();

            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);
        }
    }


    /**
     * This is required for the OAuth brokering
     *
     * @param authSession The AuthenticationSessionModel
     * @param context     The BrokeredIdentityContext
     */
    @Override
    public void authenticationFinished(AuthenticationSessionModel authSession, BrokeredIdentityContext context)
    {

        final String value = (String) context.getContextData().get(TOKEN_CLAIM_USER_TYPE);
        if (StringUtil.isNotBlank(value))
        {
            authSession.setUserSessionNote(field, value);
        }
        super.authenticationFinished(authSession, context);
    }

    @Override
    protected String getDefaultScopes()
    {
        return DEFAULT_SCOPE;
    }
}

Enter fullscreen mode Exit fullscreen mode

Provider Factory

SPI requires a provider factory which mainly to instantiate the OauthServiceIdentityProvider.

public class OauthServiceProviderFactory extends AbstractIdentityProviderFactory<OauthServiceIdentityProvider>
{

    public static final String PROVIDER_ID = "OAUTH";


    @Override
    public String getName()
    {
        return "OAuth";
    }


    @Override
    public OauthServiceIdentityProvider create(KeycloakSession session, IdentityProviderModel model)
    {
        return new OauthServiceIdentityProvider(session, new OauthServiceIdentityProviderConfig(model));
    }


    @Override
    public OauthServiceIdentityProviderConfig createConfig()
    {
        return new OauthServiceIdentityProviderConfig();
    }


    @Override
    public String getId()
    {
        return PROVIDER_ID;
    }
}
Enter fullscreen mode Exit fullscreen mode

Add attributes to Keycloak user

You can define a user attribute(s) extracted from the token claim. In the above example, the TOKEN_CLAIM_USER_ID is an attribute that I extracted from the token and added as a user attributes in doGetFederatedIdentity:

private BrokeredIdentityContext doGetFederatedIdentity(String accessToken, boolean useSessionContent)
{
      ....
      JWSInput jws = new JWSInput(accessToken);
      token = jws.readJsonContent(JsonWebToken.class);
      final String userId = String.valueOf(token.getOtherClaims().get(TOKEN_CLAIM_USER_ID));
      // define the BrokeredIdentityContext as user
      user.setUserAttribute(TOKEN_CLAIM_USER_ID, userId);
      ....
}
Enter fullscreen mode Exit fullscreen mode

Map remote token claims to Keycloak token

It is a common use case when there are claims in the remote identity token to map to Keycloak token claims. Especially when you stick to provide the same token claims as the remote identity.

To achieve that, we have to make the data available in brokered identity context data as:

private BrokeredIdentityContext doGetFederatedIdentity(String accessToken, boolean useSessionContent)
{
      JWSInput jws = new JWSInput(accessToken);
      token = jws.readJsonContent(JsonWebToken.class);
      final String userType = String.valueOf(token.getOtherClaims().get(TOKEN_CLAIM_USER_TYPE));
      // define the BrokeredIdentityContext as user
      user.getContextData().put(TOKEN_CLAIM_USER_TYPE, userType);
      ...
    }
Enter fullscreen mode Exit fullscreen mode

Then override the authenticationFinished method to add the data in the user session note:

public void authenticationFinished(AuthenticationSessionModel authSession, BrokeredIdentityContext context)
{
      final String value = (String) context.getContextData().get(TOKEN_CLAIM_USER_TYPE);
      if (StringUtil.isNotBlank(value))
      {
            authSession.setUserSessionNote(field, value);
      }
      super.authenticationFinished(authSession, context);
}
Enter fullscreen mode Exit fullscreen mode

Finally, use the scope mappers to map the Session Note as below:
Session Note mapper

Once you deploy the custom identity provider to the Keycloak cluster, you should find it listed in the identity providers page in Keycloak Admin and ready to use!


Is Terraform your preference when it comes to manage platforms? I have a complete guide on configuring custom identity providers using Terraform for you :)

I hope you find it useful.

Top comments (0)