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
- Introduction
- Table of Contents
- Initialize the project
- Create a test bench
- Implement a custom Authenticator
- Configure test bench
- Authenticator configuration
- Next steps
- Conclusion
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
Make sure to enter your own
groupId
andartifactId
.
This would generate a structure like this
.
├── pom.xml
└── src
├── main
│ └── java
│ └── dev
│ └── yakovlev_alexey
│ └── App.java
└── test
└── java
└── dev
└── yakovlev_alexey
└── AppTest.java
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>
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"
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"]
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
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
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
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
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
}
}
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:
- When the authenticator gets called the first time, send an email with a link and then show a "email sent" page
- Page should have a button to resend the email (in case delivery fails)
- 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);
}
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
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() {
}
}
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;
}
}
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);
}
This implementation does a few things:
- Creates an event that will allow you to collect data on usage and debug if needed
- Generates a link to an action token
- Sends an email with a few attributes including the user and the link
- 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);
}
}
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;
}
}
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()
inauthenticate
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
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>
msg
function allows you to place localized strings fromresources/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>
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. AndkcSanitize
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)}
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
norno_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.
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
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
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
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
.
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.
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/.
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.
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/.
Visit the link from the message.
You should see the link has been verified.
Return to the original tab and voila! You should be authenticated now.
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
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. AndMessages.java
contains all message string keys our plugin needs. Some developers put there only the keys that Java code needs but I prefer to useMessages
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() {
}
}
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() {
}
}
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() {
}
}
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;
}
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);
}
You can access authenticator configuration via
context.getAuthenticatorConfig()
.Constants
overlaps with KeycloakConstants
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.
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.
Create a separate plugin that implements a condition authenticator
HasEmailCondition
. You may useConditionalUserAttributeValue
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 (1)
Hi Alexey , Thanks for this detailed post I am going to try this and let you know the out come. I also want to achieve some template based automation to setup keycloak with realm,clientid and adding a few IDPs (upto 20 :) ). What do you prefer here SPI based extension of Keycloak Admin API. Any help from you would be appreciated .
Thanks in Advance.