Action Token in Keycloak could be very useful but tricky to implement. You may find below an article from our partner Please Open It about it’s implementation and use cases.

What is an action token ?

Action tokens are a particular type of token meant to allow unauthenticated users to perform a specific and limited action. The most common use for action tokens is for example to confirm an e-mail or reset credentials.

But their use is not restricted to a specific set of actions, in keycloak it is possible to implement custom action tokens. In order to do whatever you want and is relevant with the action token flow.

« An action token is a special instance of Json Web Token (JWT) that permits its bearer to perform some actions, e. g. to reset a password or validate e-mail address. They are usually sent to users in form of a link that points to an endpoint processing action tokens for a particular realm.

Keycloak offers four basic token types allowing the bearer to:

  • Reset credentials
  • Confirm e-mail address
  • Execute required action(s)
  • Confirm linking of an account with account in external identity provider

In addition to that, it is possible to implement any functionality that initiates or modifies authentication session using action token SPI… »

https://github.com/keycloak/keycloak-documentation/blob/master/server_development/topics/action-token-spi.adoc

Anatomy of an action token

{
  "exp": 1608657051,
  "iat": 1608656751,
  "jti": "200a261f-05d2-487e-8676-a9a72f22c2bd",
  "iss": "http://localhost:8080/auth/realms/test",
  "aud": "http://localhost:8080/auth/realms/test",
  "sub": "213a4e35-fbc8-41d2-b28c-128a5ad90a3f",
  "typ": "external-app-notification",
  "nonce": "200a261f-05d2-487e-8676-a9a72f22c2bd",
  "app-id": "123",
  "asid": "account"
}

The « typ » field will determine how the token will be handled.

The « app-id » field is a custom field, we will use it in the following example to determine which scope will be used for the access token generation.

Check this link to have a description for each field : https://github.com/keycloak/keycloak-documentation/blob/master/server_development/topics/action-token-spi.adoc#anatomy-of-action-token

Direct Naked Impersonation token exchange

Before going further with the main subject of this article, we need to go through a fast initiation of what is direct naked impersonation. This mechanism will be used to exchange our action token with a standard but limited access token in order to allow access to a specific article in a newsletter.

Direct naked impersonation is a way for your back-end to impersonate any user directly from client credentials only.

Note: Clients that are allowed to perform direct naked impersonation are very sensitive as they can virtually impersonate any user of your realm. So client_id and client_secret must be stored properly in a vault and should never be used outside of your back-end applications for your production environment.

Here is the documentation for how it works in keycloak : https://www.keycloak.org/docs/latest/securing_apps/#direct-naked-impersonation

So direct naked impersonation is just an authentication flow which allows your back-end to get an access token of any user of your realm. As it is not a standard, and somehow less secure, way to authenticate users, we highly recommend generating highly limited access token through this flow. In our case we will use it to generate an access token limited to the article referenced by the newsletter.

The following example shows how to retrieve such a limited token within the action token handler that will be presented later in this article :

        Client client = ClientBuilder.newBuilder().build();
        WebTarget target = client.target("http://keycloak:8080/auth/realms/test/protocol/openid-connect/token");
        Form form = new Form()
                .param("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
                .param("client_id", "test")
                .param("client_secret", "21b12e78-6cde-4182-a609-77f54b136ba4")
                .param("requested_subject", token.getUserId())
                .param("scope", "newsletterscope" + token.getApplicationId())
                .param("audience", "test");

        Response response = target.request().post(Entity.form(form));

How to customize it with keycloak, an example for newsletters

In this example we will see how to implement a custom action token in order to embed it in a newsletter then handle it to retrieve an access token which will allow us to load the online version of an article.

The purpose of generating an access token is to be granted to read the online article without action token management within the article service. Thus the article service can be a simple nginx with oidc plugin without any specific code.

All of the following code is contained in a keycloak plug-in.

Implement a custom action token

The first step is to extend the DefaultActionToken class, the purpose is to add some custom field needed to handle the token.

Here we will simply use the applicationId field as a scope to get a restricted access token.

public class ExternalApplicationNotificationActionToken extends DefaultActionToken {

    public static final String TOKEN_TYPE = "external-app-notification";

    private static final String JSON_FIELD_APP_ID = "app-id";

    @JsonProperty(value = JSON_FIELD_APP_ID)
    private String applicationId;

    public ExternalApplicationNotificationActionToken(String userId, int absoluteExpirationInSecs, String authenticationSessionId, String applicationId) {
        super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null, authenticationSessionId);
        this.applicationId = applicationId;
    }

    private ExternalApplicationNotificationActionToken() {
    }

    public String getApplicationId() {
        return applicationId;
    }

    public void setApplicationId(String applicationId) {
        this.applicationId = applicationId;
    }
}

How to emit an action token

Action tokens are usually generated within authenticator SPI for common usage such as e-mail confirmation.

In our case we want to allow an external service to retrieve access tokens then include them inside a newsletter.

The most straightforward way to do that is by creating a new endpoint through RealmResource Provider.

public class ActionTokenApi implements RealmResourceProvider {

    private KeycloakSession session;

    @Context
    UriInfo uriInfo;

    public ActionTokenApi(KeycloakSession session) {
        this.session = session;
    }

    @Override
    public Object getResource() {
        return this;
    }

    @Override
    public void close() {
        // Nothing to close
    }

    @POST
    @NoCache
    @Produces(MediaType.APPLICATION_JSON)
    @Path("generate-token")
    public Output getActionToken(Input input, @Context UriInfo uriInfo) {

        KeycloakContext context = session.getContext();
        // Generate action token
        String applicationId = input.getApplicationId();

        int validityInSecs = context.getRealm().getActionTokenGeneratedByUserLifespan();
        int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;
        final AuthenticationSessionModel authSession = context.getAuthenticationSession();
        final String clientId = "account";

        // Create a token used to return back to the current authentication flow
        String token = new ExternalApplicationNotificationActionToken(
          input.getUserId(),
          absoluteExpirationInSecs,
          clientId,
          applicationId
        ).serialize(
          session,
          context.getRealm(),
          uriInfo
        );

        System.out.println(token);
        System.out.println("Ok" + input.getUserId());
        return new Output(token);
    }
}

Once the realm resource implemented an action token can be retrieved this way :

curl --location --request POST 'http://localhost:8080/auth/realms/test/newsletter/generate-token' \
--header 'Content-Type: application/json' \
--data-raw '{
    "userId": "213a4e35-fbc8-41d2-b28c-128a5ad90a3f",
    "applicationId": "123"
}'

Handle the action token

The following curl example shows how to call keycloak endpoint action-token. This endpoint will verify the action token then call the corresponding handler.

curl --location --request GET 'http://localhost:8080/auth/realms/test/login-actions/action-token?key=eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJiMWIzM2U0My0yZjRjLTQ4ZTItYTBmZS04YzQ3YmFmMDZmZWYifQ.eyJleHAiOjE2MDg2NTcwNTEsImlhdCI6MTYwODY1Njc1MSwianRpIjoiMjAwYTI2MWYtMDVkMi00ODdlLTg2NzYtYTlhNzJmMjJjMmJkIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL2F1dGgvcmVhbG1zL3Rlc3QiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvdGVzdCIsInN1YiI6IjIxM2E0ZTM1LWZiYzgtNDFkMi1iMjhjLTEyOGE1YWQ5MGEzZiIsInR5cCI6ImV4dGVybmFsLWFwcC1ub3RpZmljYXRpb24iLCJub25jZSI6IjIwMGEyNjFmLTA1ZDItNDg3ZS04Njc2LWE5YTcyZjIyYzJiZCIsImFwcC1pZCI6IjEyMyIsImFzaWQiOiJhY2NvdW50IiwiYXNpZCI6ImFjY291bnQifQ.ikJYcJxYsx2Kk-yLRCVQHQHf3uVcJHmcPzVZyQw1dno'

Here is a handler implementation.

We can see in the constructor the following parameters are passed to the super() constructor :

          ExternalApplicationNotificationActionToken.TOKEN_TYPE,
          ExternalApplicationNotificationActionToken.class,

That’s how keycloak identifies the right handler depending on the token provided to the endpoint.

Speaking about the overridden handleToken method. In this handler we impersonate the required user with a limited scope through direct naked impersonation flow (as explained earlier).

A lot more methods can be overridden depending on your needs as shown in the action token quick-starts : https://github.com/keycloak/keycloak-quickstarts/blob/latest/action-token-required-action/src/main/java/org/keycloak/quickstart/actiontoken/token/ExternalApplicationNotificationActionTokenHandler.java

public class ExternalApplicationNotificationActionTokenHandler extends AbstractActionTokenHander<ExternalApplicationNotificationActionToken> {

    public static final String INITIATED_BY_ACTION_TOKEN_EXT_APP = "INITIATED_BY_ACTION_TOKEN_EXT_APP";

    public ExternalApplicationNotificationActionTokenHandler() {
        super(
          ExternalApplicationNotificationActionToken.TOKEN_TYPE,
          ExternalApplicationNotificationActionToken.class,
          Messages.INVALID_REQUEST,
          EventType.EXECUTE_ACTION_TOKEN,
          Errors.INVALID_REQUEST
        );
    }

    @Override
    public Response handleToken(ExternalApplicationNotificationActionToken token, ActionTokenContext<ExternalApplicationNotificationActionToken> tokenContext) {

        System.out.println("handleToken");
        tokenContext.getAuthenticationSession().setAuthNote(INITIATED_BY_ACTION_TOKEN_EXT_APP, "true");
        tokenContext.getAuthenticationSession().getAuthenticatedUser();

        Client client = ClientBuilder.newBuilder().build();
        WebTarget target = client.target("http://keycloak:8080/auth/realms/test/protocol/openid-connect/token");
        Form form = new Form()
                .param("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
                .param("client_id", "test")
                .param("client_secret", "21b12e78-6cde-4182-a609-77f54b136ba4")
                .param("requested_subject", token.getUserId())
                .param("scope", "newsletterscope" + token.getApplicationId())
                .param("audience", "test");

        Response response = target.request().post(Entity.form(form));
        return response;
    }
}

You may read more on our Please Open It partner’s blog : https://blog.please-open.it/action-token/

Loïc Mercier Des Rochettes
Les derniers articles par Loïc Mercier Des Rochettes (tout voir)