Seamless Migration to Keycloak: Non-OIDC OAuth 2.0 Broker
Tags: keycloak, iam
Published: 2024-03-31 00:02:47

Brokering OAuth2.0 is essential to our migration from Spring Security OAuth to Keycloak as Spring Security OAuth doesn't support [OpenID Connect](https://openid.net/specs/openid-connect-core-1_0.html) 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)](https://www.keycloak.org/docs/latest/server_development/#_providers), 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](https://github.com/keycloak/keycloak/blob/b9a7152a2911c9ca0f9f9d44de2695372b4b72b9/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java) 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](https://github.com/keycloak/keycloak/blob/b9a7152a2911c9ca0f9f9d44de2695372b4b72b9/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java#L376).

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

## 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. 
```xml
<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>
```

### 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.
```plain
com.example.keycloak.extensions.broker.oauth2.OauthServiceProviderFactory
```

### 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. 

```java
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;
    }
}
```
### OAuth Service Identity Provider 
Extend the built-in `AbstractOAuth2IdentityProvider` to get all defaults from Keycloak OAuth2.0 provider built-in implementation. 

```java
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;
    }
}

``` 
### Provider Factory
SPI requires a provider factory which mainly to instantiate the `OauthServiceIdentityProvider`. 
```java
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;
    }
}
```

## 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`:
```java
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);
      ....
}
```

## 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:
```java
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);
      ...
    }
```

Then override the `authenticationFinished` method to add the data in the user session note:
```java
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);
}
```

Finally, use the scope mappers to map the Session Note as below:
![Session Note mapper](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6evoj1t381t628rndvlb.png)

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](https://dev.to/mohammedalics/custom-identity-providers-in-keycloak-with-terraform-a-step-by-step-guide-81h) for you :)

I hope you find it useful.