DEV Community

Cover image for How to create a Keycloak plugin
Alexey Yakovlev
Alexey Yakovlev

Posted on

How to create a Keycloak plugin

Introduction

Keycloak is an open-source Identity and Access Management solution. I find it very useful for small to large teams that need to implement their own authentication system but do not have the capacity to develop a secure service themselves. It is written in Java and offers an SPI (Service Provider Interface). This means that it is easily extendable with custom implementations of existing classes and new additions via plugins.

So how do you create a plugin? In this tutorial I would like to give an example of how to develop and test a Keycloak plugin.

I should note that, while every users needs are different, it is highly likely that the plugin you want to develop already exists in this brilliant repo by user thomasdarimont. Make sure to take a look and at least get an inspiration.

In this tutorial we will be developing a plugin that would authenticate users based on a link sent to their email. At the time of writing there are no examples like this present in the forementioned repository. If you are looking for the complete project, visit my GitHub - https://github.com/yakovlev-alexey/keycloak-email-link-auth. Commit history roughly follows this tutorial.

Table of Contents

Initialize the project

First let's create a Maven project. I prefer doing that with a shell command but you may be using your favourite IDE for the same result.



mvn archetype:generate -DgroupId=dev.yakovlev_alexey -DartifactId=keycloak-email-link-auth-plugin -DarchetypeArtifactId=maven-archetype-quickstart -DarchetypeVersion=1.4 -DinteractiveMode=false


Enter fullscreen mode Exit fullscreen mode

Make sure to enter your own groupId and artifactId.

This would generate a structure like this



.
├── pom.xml
└── src
    ├── main
    │   └── java
    │       └── dev
    │           └── yakovlev_alexey
    │               └── App.java
    └── test
        └── java
            └── dev
                └── yakovlev_alexey
                    └── AppTest.java


Enter fullscreen mode Exit fullscreen mode

At this time we are not going to create unit tests although it is entirely possible. So let's remove the test folder and App.java file. Now we need to install required dependencies to develop our plugin. Make the following modifications to your pom.xml



    <properties>
        <!-- other properties -->
        <keycloak.version>19.0.3</keycloak.version>
    </properties>

    <dependencies>
        <!-- generated by maven - may be removed if you do not plan to unit test -->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.11</version>
            <scope>test</scope>
        </dependency>

        <!-- HERE GO KEYCLAOK DEPENDENCIES -->
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-server-spi-private</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-server-spi</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-services</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.keycloak</groupId>
                <artifactId>keycloak-parent</artifactId>
                <version>${keycloak.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>


Enter fullscreen mode Exit fullscreen mode

All keycloak dependencies are specified as provided. According to documentation this means that Maven will expect the runtime (JDK or in our case Keycloak) to provide those dependencies. This is exactly what we need. Still we need a centralised way to manage Keycloak dependency versions. For this we use dependencyManagement property. Make sure to run mvn install to update your dependencies.

Now we are ready to develop our plugin though it would make sense to first create a test bench where we would be able to quickly test our changes locally without modifiying your actual Keycloak instance or even deploying it.

Create a test bench

Let's create a test bench using Docker and docker-compose. I recommend using docker-compose from the start since it's highly likely that your plugin is going to have some external dependency like an SMTP server. Using docker-compose would allow you to later effortlessly add other services to create a truly isolated environment. I use the following docker-compose.yaml:



# ./docker/docker-compose.yaml
version: "3.2"

services:
    keycloak:
        build:
            context: ./
            dockerfile: Dockerfile
            args:
                - KEYCLOAK_IMAGE=${KEYCLOAK_IMAGE}
        environment:
            KEYCLOAK_ADMIN: admin
            KEYCLOAK_ADMIN_PASSWORD: admin
            DB_VENDOR: h2
        volumes:
            - ./h2:/opt/keycloak/data/h2
        ports:
            - "8024:8080"


Enter fullscreen mode Exit fullscreen mode

And the following Dockerfile:



# ./docker/Dockerfile
ARG KEYCLOAK_IMAGE

FROM $KEYCLOAK_IMAGE

USER root
COPY plugins/*.jar /opt/keycloak/providers/
USER 1000

ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "start-dev"]


Enter fullscreen mode Exit fullscreen mode

In order for docker-compose to work we need a few environment variables. Specify them in .env file.



<!-- ./docker/.env -->
COMPOSE_PROJECT_NAME=keycloak
KEYCLOAK_IMAGE=quay.io/keycloak/keycloak:19.0.3


Enter fullscreen mode Exit fullscreen mode

Create plugins folder inside docker directory. This is where jar files with compiled plugins will go.



docker
├── .env
├── Dockerfile
├── docker-compose.yaml
├── h2
└── plugins
    └── .gitkeep


Enter fullscreen mode Exit fullscreen mode

Make sure to ignore h2 folder and plugins contents in your VCS. Your .gitignore might look something like this:



# ./.gitignore
target
docker/h2

docker/plugins/*
!docker/plugins/.gitkeep


Enter fullscreen mode Exit fullscreen mode

Finally you can run docker-compose up --build keycloak in your docker folder to start your test instance of Keycloak.

Implement a custom Authenticator

Now back to our goal. We want to authenticate users based on the link we send to their email. To do that we implement a custom Authenticator. Authenticator is basically a step in the process of authenticating a user. It may be anything from a form that requires input from user to complex redirect.

In order to get an idea how to create an authenticator (or any other class you might need that leverages SPI for that matter) I recommend taking a look at Keycloak source code hosted on GitHub - https://github.com/keycloak/keycloak. As you might expect Keycloak has a very large codebase therefore it would be easier to use GitHub search to find the class you might need. In our case you may find authenticators directory interesting - https://github.com/keycloak/keycloak/tree/main/services/src/main/java/org/keycloak/authentication/authenticators. I will not go into detail about existing Keycloak authenticators and their implementations and rather start scaffolding our own authenticator.

Another thing I recommend is to follow the same folder structure as Keycloak. Therefore our plugin should look something like this:



src
└── main
    └── java
        └── dev
            └── yakovlev_alexey
                └── keycloak
                    └── authentication
                        └── authenticators
                            └── browser
                                └── EmailLinkAuthenticator.java


Enter fullscreen mode Exit fullscreen mode

Create class EmailLinkAuthenticator and implement Authenticator interface from org.keycloak.authentication.Authenticator. After stubbing required methods your code could look like this:



package dev.yakovlev_alexey.keycloak.authentication.authenticators.browser;

import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;

public class EmailLinkAuthenticator implements Authenticator {

    @Override
    public void close() {
        // TODO Auto-generated method stub
    }

    @Override
    public void action(AuthenticationFlowContext context) {
        // TODO Auto-generated method stub
    }

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        // TODO Auto-generated method stub
    }

    @Override
    public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
        // TODO Auto-generated method stub
        return false;
    }

    @Override
    public boolean requiresUser() {
        // TODO Auto-generated method stub
        return false;
    }

    @Override
    public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
        // TODO Auto-generated method stub
    }

}


Enter fullscreen mode Exit fullscreen mode

There is a bunch of methods but we will need only some of them. Important ones are authenticate which is called when user enters this Authenticator in the Authentication flow and action which is called when user submits the form.

To start with we may specify that this Authenticator requires a user. This basically means that at the point of reaching this authenticator we should already have an idea who the user may want to authenticate as. In simpler terms prior to reaching our authenticator the user should enter their username. In order to show that to Keycloak just return true from requiresUser method.

configuredFor method allows us to tell Keycloak whether we are able to authenticate the user in a certain context. Context being the realm with its settings, Keycloak session and the user being authenticated. In our case we only need the user to have an email. So let's return user.getEmail() != null from configuredFor.

Now it is time to implement the actual logic for this authenticator. We need it to do a few things:

  1. When the authenticator gets called the first time, send an email with a link and then show a "email sent" page
  2. Page should have a button to resend the email (in case delivery fails)
  3. Page should have a button to submit the verification (e.g. I confirmed the verification in a different browser/device and want to continue in this tab)

The closest authenticator to ours is VerifyEmail required action. While it is not really an authenticator it is very similar. Looking at the implementation we see that in order to send an email it creates a token and encodes it in an action token URL.



private Response sendVerifyEmail(KeycloakSession session, LoginFormsProvider forms, UserModel user, AuthenticationSessionModel authSession, EventBuilder event) throws UriBuilderException, IllegalArgumentException {
    RealmModel realm = session.getContext().getRealm();
    UriInfo uriInfo = session.getContext().getUri();

    int validityInSecs = realm.getActionTokenGeneratedByUserLifespan(VerifyEmailActionToken.TOKEN_TYPE);
    int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;

    String authSessionEncodedId = AuthenticationSessionCompoundId.fromAuthSession(authSession).getEncodedId();
    VerifyEmailActionToken token = new VerifyEmailActionToken(user.getId(), absoluteExpirationInSecs, authSessionEncodedId, user.getEmail(), authSession.getClient().getClientId());
    UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo),
            authSession.getClient().getClientId(), authSession.getTabId());
    String link = builder.build(realm.getName()).toString();
    long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs);

    try {
        session
            .getProvider(EmailTemplateProvider.class)
            .setAuthenticationSession(authSession)
            .setRealm(realm)
            .setUser(user)
            .sendVerifyEmail(link, expirationInMinutes);
        event.success();
    } catch (EmailException e) {
        logger.error("Failed to send verification email", e);
        event.error(Errors.EMAIL_SEND_FAILED);
    }

    return forms.createResponse(UserModel.RequiredAction.VERIFY_EMAIL);
}


Enter fullscreen mode Exit fullscreen mode

Custom Action Token

I think it makes sense to copy the implementation except our parameters would be slightly different to account for different contexts in authenticators and requried actions. But to properly use this implementation we need an action token of our own. Action token is a class that represents a JWT. It extends DefaultActionToken class and has a corresponding ActionTokenHandler class. Tokens can be encoded into a URL to which action token handler will respond. Let's create our own action token by copying VerifyEmail one.

EmailLinkActionToken should be located in keycloak.authentication.actiontoken.emaillink package. Your folder structure should look like the following one:



src
└── main
    └── java
        └── dev
            └── yakovlev_alexey
                └── keycloak
                    └── authentication
                        ├── actiontoken
                        │   └── emaillink
                        │       ├── EmailLinkActionToken.java
                        │       └── EmailLinkActionTokenHandler.java
                        └── authenticators
                            └── browser
                                └── EmailLinkAuthenticator.java


Enter fullscreen mode Exit fullscreen mode

Let's implement EmailLinkActionToken by copying VerifyEmailActionToken and replacing the name and removing originalAuthenticationSessionId:



package dev.yakovlev_alexey.keycloak.authentication.actiontoken.emaillink;

import org.keycloak.authentication.actiontoken.DefaultActionToken;

public class EmailLinkActionToken extends DefaultActionToken {
    public static final String TOKEN_TYPE = "email-link";

    public EmailLinkActionToken(String userId, int absoluteExpirationInSecs, String compoundAuthenticationSessionId,
            String email, String clientId) {
        super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null, compoundAuthenticationSessionId);
        this.issuedFor = clientId;

        setEmail(email);
    }

    private EmailLinkActionToken() {
    }
}


Enter fullscreen mode Exit fullscreen mode

As you might notice I removed originalAuthenticationSessionId. It is used to create a new authenticaton session by reissuing the token and using its link as the submit action to confirm sign up in a different browser.

Next let's implement the handler for it. This time just copying will not work: contexts are very different. First let's settle on an implementation where visiting the link immediately gives the user consent (unlike the VerifyEmail one where manually clicking a button is required). After visiting the link an info page will be shown. To achieve this behaviour we will roughly follow the same procedure as in VerifyEmailActionTokenHandler except I will try to comment important parts since the code is not as easy to understand.



package dev.yakovlev_alexey.keycloak.authentication.actiontoken.emaillink;

import org.keycloak.authentication.actiontoken.AbstractActionTokenHandler;
import org.keycloak.TokenVerifier.Predicate;
import org.keycloak.authentication.actiontoken.*;
import org.keycloak.events.*;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationSessionManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionCompoundId;
import org.keycloak.sessions.AuthenticationSessionModel;

import dev.yakovlev_alexey.keycloak.authentication.authenticators.browser.EmailLinkAuthenticator;

import java.util.Collections;

import javax.ws.rs.core.Response;

public class EmailLinkActionTokenHandler extends AbstractActionTokenHandler<EmailLinkActionToken> {

    public EmailLinkActionTokenHandler() {
        super(
                EmailLinkActionToken.TOKEN_TYPE,
                EmailLinkActionToken.class,
                Messages.STALE_VERIFY_EMAIL_LINK,
                EventType.VERIFY_EMAIL,
                Errors.INVALID_TOKEN);
    }

    @Override
    public Predicate<? super EmailLinkActionToken>[] getVerifiers(
            ActionTokenContext<EmailLinkActionToken> tokenContext) {
        // this is different to VerifyEmailActionTokenHandler implementation because
        // since its implementation a helper was added
        return TokenUtils.predicates(verifyEmail(tokenContext));
    }

    @Override
    public Response handleToken(EmailLinkActionToken token, ActionTokenContext<EmailLinkActionToken> tokenContext) {
        AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession();
        UserModel user = authSession.getAuthenticatedUser();
        KeycloakSession session = tokenContext.getSession();
        EventBuilder event = tokenContext.getEvent();
        RealmModel realm = tokenContext.getRealm();

        event.event(EventType.VERIFY_EMAIL)
                .detail(Details.EMAIL, user.getEmail())
                .success();

        // verify user email as we know it is valid as this entry point would never have
        // gotten here
        user.setEmailVerified(true);

        // fresh auth session means that the link was open in a different browser window
        // or device
        if (!tokenContext.isAuthenticationSessionFresh()) {
            // link was opened in the same browser session (session is not fresh) - save the
            // user a click and continue authentication in the new (current) tab
            // previous tab will be thrown away
            String nextAction = AuthenticationManager.nextRequiredAction(tokenContext.getSession(), authSession,
                    tokenContext.getRequest(), tokenContext.getEvent());
            return AuthenticationManager.redirectToRequiredActions(tokenContext.getSession(), tokenContext.getRealm(),
                    authSession, tokenContext.getUriInfo(), nextAction);
        }

        AuthenticationSessionCompoundId compoundId = AuthenticationSessionCompoundId
                .encoded(token.getCompoundAuthenticationSessionId());

        AuthenticationSessionManager asm = new AuthenticationSessionManager(session);
        asm.removeAuthenticationSession(realm, authSession, true);

        ClientModel originalClient = realm.getClientById(compoundId.getClientUUID());
        // find the original authentication session
        // (where the tab is waiting to confirm)
        authSession = asm.getAuthenticationSessionByIdAndClient(realm, compoundId.getRootSessionId(),
                originalClient, compoundId.getTabId());

        if (authSession != null) {
            authSession.setAuthNote(EmailLinkAuthenticator.EMAIL_LINK_VERIFIED, user.getEmail());
        } else {
            // if no session was found in the same instance it might still be in the same
            // cluster if you have multiple replicas of Keycloak
            session.authenticationSessions().updateNonlocalSessionAuthNotes(
                    compoundId,
                    Collections.singletonMap(EmailLinkAuthenticator.EMAIL_LINK_VERIFIED,
                            token.getEmail()));
        }

        // show success page
        return session.getProvider(LoginFormsProvider.class)
                .setAuthenticationSession(authSession)
                .setSuccess(Messages.EMAIL_VERIFIED, token.getEmail())
                .createInfoPage();
    }

    // we do not really want users to authenticate using the same link multiple times
    @Override
    public boolean canUseTokenRepeatedly(EmailLinkActionToken token,
            ActionTokenContext<EmailLinkActionToken> tokenContext) {
        return false;
    }
}


Enter fullscreen mode Exit fullscreen mode

To practice a little yourself try to reimplement this action token and handler to require additional confirmation (like it works with VerifyEmailActionToken).

Authenticator implementation

With action token completed we may continue implementing EmailLinkAuthenticator. We will use VerifyEmail.sendVerifyEmailEvent as reference but once again since context is very different we will change a lot of things.



    private static final Logger logger = Logger.getLogger(EmailLinkAuthenticator.class);

    protected void sendVerifyEmail(KeycloakSession session, AuthenticationFlowContext context, UserModel user)
            throws UriBuilderException, IllegalArgumentException {
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        RealmModel realm = session.getContext().getRealm();

        // use the same lifespan as other tokens by getting from realm configuration
        int validityInSecs = realm.getActionTokenGeneratedByUserLifespan(EmailLinkActionToken.TOKEN_TYPE);
        long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs);

        String link = buildEmailLink(session, context, user, validityInSecs);

        // event is used to achieve better observability over what happens in Keycloak
        EventBuilder event = getSendVerifyEmailEvent(context, user);

        Map<String, Object> attributes = getMessageAttributes(user, realm.getDisplayName(), link, expirationInMinutes);

        try {
            session.getProvider(EmailTemplateProvider.class)
                    .setRealm(realm)
                    .setUser(user)
                    .setAuthenticationSession(authSession)
                    // hard-code some of the variables - we will return here later
                    .send("emailLinkSubject", "email-link-email.ftl", attributes);

            event.success();
        } catch (EmailException e) {
            logger.error("Failed to send verification email", e);
            event.error(Errors.EMAIL_SEND_FAILED);
        }

        showEmailSentPage(context, user);
    }

    /**
     * Generates an action token link by encoding `EmailLinkActionToken` with user
     * and session data
     */
    protected String buildEmailLink(KeycloakSession session, AuthenticationFlowContext context, UserModel user,
            int validityInSecs) {
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        RealmModel realm = session.getContext().getRealm();
        UriInfo uriInfo = session.getContext().getUri();

        int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;

        String authSessionEncodedId = AuthenticationSessionCompoundId.fromAuthSession(authSession).getEncodedId();
        EmailLinkActionToken token = new EmailLinkActionToken(user.getId(), absoluteExpirationInSecs,
                authSessionEncodedId, user.getEmail(), authSession.getClient().getClientId());
        UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo),
                authSession.getClient().getClientId(), authSession.getTabId());
        String link = builder.build(realm.getName()).toString();

        return link;
    }

    /**
     * Creates a Map with context required to render email message
     */
    protected Map<String, Object> getMessageAttributes(UserModel user, String realmName, String link,
            long expirationInMinutes) {
        Map<String, Object> attributes = new HashMap<>();

        attributes.put("user", user);
        attributes.put("realmName", realmName);
        attributes.put("link", link);
        attributes.put("expirationInMinutes", expirationInMinutes);

        return attributes;
    }

    /**
     * Creates a builder for `SEND_VERIFY_EMAIL` event
     */
    protected EventBuilder getSendVerifyEmailEvent(AuthenticationFlowContext context, UserModel user) {
        AuthenticationSessionModel authSession = context.getAuthenticationSession();

        EventBuilder event = context.getEvent().clone().event(EventType.SEND_VERIFY_EMAIL)
                .user(user)
                .detail(Details.USERNAME, user.getUsername())
                .detail(Details.EMAIL, user.getEmail())
                .detail(Details.CODE_ID, authSession.getParentSession().getId())
                .removeDetail(Details.AUTH_METHOD)
                .removeDetail(Details.AUTH_TYPE);

        return event;
    }

    /**
     * Displays email link form
     */
    protected void showEmailSentPage(AuthenticationFlowContext context, UserModel user) {
        String accessCode = context.generateAccessCode();
        URI action = context.getActionUrl(accessCode);

        Response challenge = context.form()
                .setStatus(Response.Status.OK)
                .setActionUri(action)
                .setExecution(context.getExecution().getId())
                .createForm("email-link-form.ftl");

        context.forceChallenge(challenge);
    }


Enter fullscreen mode Exit fullscreen mode

This implementation does a few things:

  1. Creates an event that will allow you to collect data on usage and debug if needed
  2. Generates a link to an action token
  3. Sends an email with a few attributes including the user and the link
  4. Shows a form that allows you to resend the email or confirm that you verified your authentication via link

I took the liberty to hard-code a few variables temporarily - we will return to replace them with actual constants or configruation parameters. This includes "emailLinkSubject" being the message id for email subject and email and form templates: "email-link-email.ftl", "email-link-form.ftl". Messages and templates are stored in resources directory in Keycloak. We will put them there later.

Now let's implement the most important methods: action and authenticate.



    @Override
    public void authenticate(AuthenticationFlowContext context) {
        // the method gets called when first reaching this authenticator and after page
        // refreshes
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        KeycloakSession session = context.getSession();
        RealmModel realm = context.getRealm();
        UserModel user = context.getUser();

        // cant really do anything without smtp server
        if (realm.getSmtpConfig().isEmpty()) {
            ServicesLogger.LOGGER.smtpNotConfigured();
            context.attempted();
            return;
        }

        // if email was verified allow the user to continue
        if (Objects.equals(authSession.getAuthNote(EMAIL_LINK_VERIFIED), user.getEmail())) {
            context.success();
            return;
        }

        // do not allow resending e-mail by simple page refresh
        if (!Objects.equals(authSession.getAuthNote(Constants.VERIFY_EMAIL_KEY), user.getEmail())) {
            authSession.setAuthNote(Constants.VERIFY_EMAIL_KEY, user.getEmail());
            sendVerifyEmail(session, context, user);
        } else {
            showEmailSentPage(context, user);
        }
    }

    @Override
    public void action(AuthenticationFlowContext context) {
        // this method gets called when user submits the form
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        KeycloakSession session = context.getSession();
        UserModel user = context.getUser();

        // if link was already open continue authentication
        if (Objects.equals(authSession.getAuthNote(EMAIL_LINK_VERIFIED), user.getEmail())) {
            context.success();
            return;
        }

        MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
        String action = formData.getFirst("submitAction");

        // if the form was submitted with an action of `resend` resend the email
        // otherwise just show the same page
        if (action != null && action.equals("resend")) {
            sendVerifyEmail(session, context, user);
        } else {
            showEmailSentPage(context, user);
        }
    }


Enter fullscreen mode Exit fullscreen mode

The last thing we want to do with our authenticator class is create a factory for it. Keycloak uses factories to instantiate providers. For our action token handler factory was implemented in the base class. So it is entirely possible to implement both the provider and the factory in the same class. In this case we will implement it in a different class EmailLinkAuthenticatorFactory.



package dev.yakovlev_alexey.keycloak.authentication.authenticators.browser;

import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;

import java.util.List;

public class EmailLinkAuthenticatorFactory implements AuthenticatorFactory {
    public static final EmailLinkAuthenticator SINGLETON = new EmailLinkAuthenticator();

    @Override
    public String getId() {
        return "email-link-authenticator";
    }

    @Override
    public String getDisplayType() {
        return "Email Link Authentication";
    }

    @Override
    public String getHelpText() {
        return "Authenticates the user with a link sent to their email";
    }

    @Override
    public String getReferenceCategory() {
        return null;
    }

    @Override
    public boolean isConfigurable() {
        return false;
    }

    @Override
    public boolean isUserSetupAllowed() {
        return false;
    }

    @Override
    public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
        return new AuthenticationExecutionModel.Requirement[] {
                AuthenticationExecutionModel.Requirement.REQUIRED,
                AuthenticationExecutionModel.Requirement.DISABLED,
        };
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return null;
    }

    @Override
    public void init(Config.Scope config) {
    }

    @Override
    public void postInit(KeycloakSessionFactory factory) {
    }

    @Override
    public void close() {
    }

    @Override
    public Authenticator create(KeycloakSession session) {
        // a common pattern in Keycloak codebase is to use singletons for factories
        return SINGLETON;
    }
}


Enter fullscreen mode Exit fullscreen mode

At the moment we do not allow any configuration for our authenticator. Also our authenticator is either required or disabled. This means that it is not possible to bypass this authenticator by not having an email. However if this is what you want you may make this authenticator optional. You would also need to call context.attempted() in authenticate method in the authenticator class.

Another option to allow emailless users to skip this authenticator is to create another conditional authenticator - however this is out of scope for this tutorial.

Custom resources for plugins

We now have all the code we need for desired functionality to work. However we still miss the form and messages to display. To add resources to our plugins let's create resources directory in src/main. In it create a subdirectory theme-resources. It is used by Kecyloak to import templates and messages.

Messages should be stored in messages folder in a .properties file named like messages_{language}.properties.

Templates similarly are stored in templates folder. Email message templates are split into html and text subfolders. Some email clients may not render HTML - text version will be used then. Form templates should be stored at the root of templates.

Other resources are stored similarly stored in css, js, img directories. Read more about in official docs.



src
└── main
    ├── java
    └── resources
        └── theme-resources
            ├── messages
            │   └── messages_en.properties
            └── templates
                ├── html
                │ └── email-link-email.ftl
                ├── text
                │ └── email-link-email.ftl
                └── email-link-form.ftl


Enter fullscreen mode Exit fullscreen mode

Keycloak uses FreeMaker to store and render templates. Read more about how Keycloak manages its themes in the official documentation.

To implement the form in email-link-form.ftl for our plugin we will use the following template:



<#import "template.ftl" as layout>
<@layout.registrationLayout; section>
    <#if section = "header">
        ${msg("emailLinkTitle")}
    <#elseif section = "form">
        <form id="kc-register-form" action="${url.loginAction}" method="post">
            <div class="${properties.kcFormGroupClass!}">
                <button type="submit" class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="submitAction" id="confirm" value="confirm">${msg("emailLinkCheck")}</button>
                <button type="submit" class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="submitAction" id="resend" value="resend">${msg("emailLinkResend")}</button>
            </div>
        </form>
    </#if>
</@layout.registrationLayout>


Enter fullscreen mode Exit fullscreen mode

msg function allows you to place localized strings from resources/messages. They will be substituted by Keycloak during rendering. Otherwise the syntax is pure FreeMaker.

You can see our submit buttons have a name and a value. When you submit an HTML form via a button with a value this value is appended to form data sent to the server. This is what we were using in action handler to make sure we only resend email when user actually asks for it by clicking the button.

As for emails templates look a little different. HTML version in email-link-email.ftl look like this:



<html>
<body>
${kcSanitize(msg("emailLinkEmailBodyHtml",link, expirationInMinutes, realmName))?no_esc}
</body>
</html>


Enter fullscreen mode Exit fullscreen mode

Here we just use a message for all contents of our email. We also pass variables that would be substitued into the message, more on that later. no_esc allows us to render HTML as HTML and not as text. And kcSanitize is needed to ensure no dangerous markup ends up in the resulting document.

The text version of the same email look like this:



<#ftl output_format="plainText">
${msg("emailLinkEmailBody",link, expirationInMinutes, realmName)}


Enter fullscreen mode Exit fullscreen mode

Here we explicitely tell that we want a plain text document and not an HTML file. Otherwise it is basically the same except we do not want HTML here therefore do not call neither kcSanitize nor no_esc.

At this point we have all the templates except we do not have any of the messages our templates (and Providers) use. Our messages_en.properties file should look something like this:



emailLinkSubject=Your authentication link

emailLinkResend=Resend email
emailLinkCheck=I followed the link

emailLinkEmailBody=Your authentication link is {0}.\nIt will be valid for {1} minutes. Follow it to authenticate in {2} and then return to the original browser tab.
emailLinkEmailBodyHtml=<a href="{0}">Click here to authenticate</a>. The link will be valid for {1} minutes. Follow it to authenticate in {2} and then return to the original browser tab.


Enter fullscreen mode Exit fullscreen mode

You can see its just a file containing all the messages we need. If you specify any of the messages that already exist in Keycloak, they will not get replaced. To replace existing messages use themes. Some messages may contain HTML markup. But as I already said they need to be properly sanitized and unescape to be used.

Notify Keycloak of new Providers

At this point we have the code and the resources for our plugin. However if we build it, put into Keycloak plugins folder and run Keycloak nothing will happen. Keycloak will not know what to do with our code. To tell Keycloak that we want it to inject certain classes we have to add certain meta information to our built jar file. For this use resources/META-INF directory.



src
└── main
    ├── java
    └── resources
        ├── theme-resources
        └── META-INF
            └── services
                ├── org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory
                └── org.keycloak.authentication.AuthenticatorFactory


Enter fullscreen mode Exit fullscreen mode

Here we created 2 files in services subdirectory. Each file in this directory should be named after a base interface or class that our own classes implement or extend.

In each file we need to specify all classes of our own that derivate from the filename class on separate lines.



# org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory
dev.yakovlev_alexey.keycloak.authentication.actiontoken.emaillink.EmailLinkActionTokenHandler

# org.keycloak.authentication.AuthenticatorFactory
dev.yakovlev_alexey.keycloak.authentication.authenticators.browser.EmailLinkAuthenticatorFactory


Enter fullscreen mode Exit fullscreen mode

Finally we can build our project by running mvn clean package. A file with the name of keycloak-email-link-auth-plugin-1.0-SNAPSHOT.jar should be created in target folder. Copy this file to docker/plugins directory.

Configure test bench

We are almost ready to test our plugin. However in order to properly do this we would need an SMTP server since our plugin sends emails. You could use some real SMTP server. But that would likely cost money and you will have to use a real email. It is much easier to use a mail trap service like MailHog.

Add it as service to docker-compose.yaml



version: "3.2"

services:
    keycloak:
        build:
            context: ./
            dockerfile: Dockerfile
            args:
                - KEYCLOAK_IMAGE=${KEYCLOAK_IMAGE}
        environment:
            KEYCLOAK_ADMIN: admin
            KEYCLOAK_ADMIN_PASSWORD: admin
            DB_VENDOR: h2
        volumes:
            - ./h2:/opt/keycloak/data/h2
        ports:
            - "8024:8080"

    mailhog:
        image: mailhog/mailhog
        ports:
            - 1025:1025
            - 8025:8025


Enter fullscreen mode Exit fullscreen mode

Port 1025 is used for SMTP and 8025 for web UI/API.

Now you can run Docker using docker-compose up --build keycloak. Visit http://localhost:8024 and enter administration console. Username and password should both be admin as stated in the yaml.

To use our authenticator you need to configure an authenticator flow that uses it. Flows are sequences of authenticators. By default browser authentication flow is used. Go ahead and copy it as browser-email in Authentication tab.

Replace Username Password Form with Username Form. Since we want to authenticate based on the link sent to the email we do not really want password from our users. Though it is also possible to keep both password and emails to implement a sort of 2 factor authentication.

After Username Form add our Email Link Authentication and make it Required.

Flows screenshot

Next configure SMTP server in Realm Settings -> Email. Specify any From value. Host for the connection should be mailhog and port 1025. No SSL or authentication is used.

SMTP configuration

After you saved Email configuration run Test Connection. Email should be sent successfully and you should see a test message in MailHog web UI at http://localhost:8025/.

MailHog UI

Make sure your admin user has a valid email (not necessarily one that you have access to) in Users tab.

Finally enter Clients, find account-console and in Advanced tab configure Authentication flow overrides. Browser flow should be overriden with browser-email. This will allow you to easily test your plugin by entering http://localhost:8024/realms/master/account/ and not break the admin panel if your changes do not work.

Authentication flow overrides in client

Everything is set up and you can now visit http://localhost:8024/realms/master/account/ and enter admin as your username. You should see the form we implemented earlier and an email should be visible in MailHog UI at http://localhost:8025/.

Email authentication form

Visit the link from the message.

Message in MailHog UI

You should see the link has been verified.

Verified link

Return to the original tab and voila! You should be authenticated now.

Authenticated in account console

Authenticator configuration

You may remember that we hardcoded some of the variables that should either be configurable or at least stored separately. Let's go back and fix that.

Create a folder to store all utility classes for our authenticator.



src
└── main
    └── java
        └── dev
            └── yakovlev_alexey
                └── keycloak
                    └── authentication
                    │ ├── actiontoken
                    │ └── authenticators
                    └── emaillink
                        ├── ConfigurationProperties.java
                        ├── Constants.java
                        └── Messages.java


Enter fullscreen mode Exit fullscreen mode

ConfigurationProperties.java is a class with constants to enable configuration for our authenticator. Constants.java stores generic variable that do not need to be configurable. And Messages.java contains all message string keys our plugin needs. Some developers put there only the keys that Java code needs but I prefer to use Messages class as a list for all strings (even if they are only used in templates) so that it is easy to add localized versions (e.g. messages_de.properties file).

Let's implement ConfigurationProperties class:



package dev.yakovlev_alexey.keycloak.emaillink;

import org.keycloak.provider.ProviderConfigProperty;

import java.util.Arrays;
import java.util.List;

import static org.keycloak.provider.ProviderConfigProperty.*;

public final class ConfigurationProperties {
    public static final String EMAIL_TEMPLATE = "EMAIL_TEMPLATE";
    public static final String PAGE_TEMPLATE = "PAGE_TEMPLATE";

    public static final String RESEND_ACTION = "RESEND_ACTION";

    public static final List<ProviderConfigProperty> PROPERTIES = Arrays.asList(
            new ProviderConfigProperty(EMAIL_TEMPLATE,
                    "FTL email template name",
                    "Will be used as the template for emails with the link",
                    STRING_TYPE, "email-link-email.ftl"),
            new ProviderConfigProperty(PAGE_TEMPLATE,
                    "FTL page template name",
                    "Will be used as the template for email link page",
                    STRING_TYPE, "email-link-form.ftl"),
            new ProviderConfigProperty(RESEND_ACTION,
                    "Resend Email Link action",
                    "Action which corresponds to user manually asking to resend email with link",
                    STRING_TYPE, "resend"));

    private ConfigurationProperties() {
    }
}


Enter fullscreen mode Exit fullscreen mode

And Constants class:



package dev.yakovlev_alexey.keycloak.emaillink;

public final class Constants {
    public static final String EMAIL_LINK_SUBMIT_ACTION_KEY = "submitAction";

    private Constants() {
    }
}


Enter fullscreen mode Exit fullscreen mode

And finally Messages class:



package dev.yakovlev_alexey.keycloak.emaillink;

public final class Messages {
    public static final String EMAIL_LINK_SUBJECT = "emailLinkSubject";
    public static final String EMAIL_LINK_STALE = "emailLinkStale";

    public static final String EMAIL_LINK_SUCCESS = "emailLinkSuccess";

    public static final String EMAIL_LINK_TITLE = "emailLinkTitle";
    public static final String EMAIL_LINK_RESEND = "emailLinkResend";
    public static final String EMAIL_LINK_CHECK = "emailLinkCheck";

    public static final String EMAIL_LINK_EMAIL_BODY = "emailLinkEmailBody";
    public static final String EMAIL_LINK_EMAIL_BODY_HTML = "emailLinkEmailBodyHtml";

    private Messages() {
    }
}


Enter fullscreen mode Exit fullscreen mode

A few changes in EmailLinkAuthenticatorFactory are needed to make it configurable:



    @Override
    public boolean isConfigurable() {
        return true;
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return ConfigurationProperties.PROPERTIES;
    }


Enter fullscreen mode Exit fullscreen mode

Now update EmailLinkAuthenticator class to leverage new constants:



    // ***

    @Override
    public void action(AuthenticationFlowContext context) {
        // this method gets called when user submits the form
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        AuthenticatorConfigModel config = context.getAuthenticatorConfig();
        KeycloakSession session = context.getSession();
        UserModel user = context.getUser();

        // if link was already open continue authentication
        if (Objects.equals(authSession.getAuthNote(EMAIL_LINK_VERIFIED), user.getEmail())) {
            context.success();
            return;
        }

        MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
        String action = formData
                .getFirst(dev.yakovlev_alexey.keycloak.emaillink.Constants.EMAIL_LINK_SUBMIT_ACTION_KEY);

        // if the form was submitted with an action of `resend` resend the email
        // otherwise just show the same page
        if (action != null && action.equals(config.getConfig().get(ConfigurationProperties.RESEND_ACTION))) {
            sendVerifyEmail(session, context, user);
        } else {
            showEmailSentPage(context, user);
        }
    }

    // ***

    private void sendVerifyEmail(KeycloakSession session, AuthenticationFlowContext context, UserModel user)
            throws UriBuilderException, IllegalArgumentException {
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        AuthenticatorConfigModel config = context.getAuthenticatorConfig();
        RealmModel realm = session.getContext().getRealm();

        // use the same lifespan as other tokens by getting from realm configuration
        int validityInSecs = realm.getActionTokenGeneratedByUserLifespan(EmailLinkActionToken.TOKEN_TYPE);
        long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs);

        String link = buildEmailLink(session, context, user, validityInSecs);

        // event is used to achieve better observability over what happens in Keycloak
        EventBuilder event = getSendVerifyEmailEvent(context, user);

        Map<String, Object> attributes = getMessageAttributes(user, realm.getDisplayName(), link, expirationInMinutes);

        try {
            session.getProvider(EmailTemplateProvider.class)
                    .setRealm(realm)
                    .setUser(user)
                    .setAuthenticationSession(authSession)
                    // hard-code some of the variables - we will return here later
                    .send(config.getConfig().get(Messages.EMAIL_LINK_SUBJECT),
                            config.getConfig().get((ConfigurationProperties.EMAIL_TEMPLATE)), attributes);

            event.success();
        } catch (EmailException e) {
            logger.error("Failed to send verification email", e);
            event.error(Errors.EMAIL_SEND_FAILED);
        }

        showEmailSentPage(context, user);
    }


    // ***

    /**
     * Displays email link form
     */
    protected void showEmailSentPage(AuthenticationFlowContext context, UserModel user) {
        AuthenticatorConfigModel config = context.getAuthenticatorConfig();
        String accessCode = context.generateAccessCode();
        URI action = context.getActionUrl(accessCode);

        Response challenge = context.form()
                .setStatus(Response.Status.OK)
                .setActionUri(action)
                .setExecution(context.getExecution().getId())
                .createForm(config.getConfig().get(ConfigurationProperties.PAGE_TEMPLATE));

        context.forceChallenge(challenge);
    }


Enter fullscreen mode Exit fullscreen mode

You can access authenticator configuration via context.getAuthenticatorConfig(). Constants overlaps with Keycloak Constants class used in the same file therefore to avoid name clashes full package name is specified here.

Now when you enter Authentication tab and edit browser-email flow you should see a gear button beside Email Likn Authentication that will open settings for this authenticator.

In Keycloak 19 there is a bug in interface that does not allow opening settings for authenticators. Refer to this issue.

Next steps

To make sure you get a good grasp of how to develop Keycloak plugins I recommend you do a few "homework" tasks yourself. There are some things that could be improved about this plugin that I already mentioned.

  1. Implement a configuration option that would enable a confirmation screen when you open the link. This screen should have a "confirm" button. Only after clicking this button you may continue in your original tab. If link is open in the same browser window user should be immideately authenticated as it works now.

  2. Create a separate plugin that implements a condition authenticator HasEmailCondition. You may use ConditionalUserAttributeValue as reference. Use both plugins in a test bench to configure a flow where users with password set up are authenticated via password and others are required to follow email link.

Conclusion

I hope this tutorial gave you an idea how to create Keycloak plugins. There is much more to it, many internal SPIs could be implemented to achieve different scenarios. My goal was to focus on the basics: plugin composition, resources imports and getting inspiration from Keycloak source code. If you have any questions, ideas or suggestions make sure to leave them in GitHub issues.

Find the complete code on my GitHub - https://github.com/yakovlev-alexey/keycloak-email-link-auth.

Top comments (0)