Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
---
id: email_otp_for_imported_voters
title: Email OTP for Imported Voters
---

<!--
SPDX-FileCopyrightText: 2026 Sequent Tech Inc <legal@sequentech.io>
SPDX-License-Identifier: AGPL-3.0-only
-->

# Email OTP for Imported Voters

This tutorial explains how to make imported voters receive a one-time password by
email at login, without asking them to enrol anything first. It uses the
**Auto-create OTP credential** option (`autoCreateCredentialAttribute`) of the
`message-otp-authenticator` Keycloak extension.

## Why this option exists

The OTP step is normally guarded by Keycloak's **Condition - user configured**,
which asks the authenticator whether the voter is configured for it. Historically
that answer was `false` unless the voter had a stored `message-otp` credential.

Voters imported or edited through the admin portal get their email and mobile
attributes set, but no `message-otp` credential is ever stored for them. The
result is that the OTP sub-flow is silently skipped: the voter signs in with the
password alone, and no second factor is ever requested. In flows where the OTP is
the only alternative left, the login instead fails with a generic
`invalid_user_credentials` error.

With **Auto-create OTP credential** enabled, the authenticator creates the
`message-otp` credential during login for any non-deferred voter that already has
an email address or a mobile number, so the OTP step runs on the voter's very
first login.

The option is **disabled by default**: enabling it is an explicit decision per
authentication flow.

## Prerequisites

- Voters imported with an **email address** (or a mobile number for SMS).
- A working email sender for the realm. In the development environment the dummy
sender writes the message to the Keycloak log instead of sending it.
- Access to the Keycloak admin console for the election event realm.

## Step 1: add the OTP sub-flow to the browser flow

In the election event realm, open **Authentication** and select the browser flow
used by the event (`sequent browser flow`). The OTP step lives in a
**Conditional** sub-flow that contains **Condition - user configured** followed by
**OTP - Message via Email/SMS**, both **Required**, placed after the username and
password step:

![Browser flow with the message OTP sub-flow](./assets/otp_auto_credential_browser_flow.png)

## Step 2: enable the option on the authenticator

Open the settings (the gear icon) of the **OTP - Message via Email/SMS** step and
set:

- **Message Courier**: `EMAIL` (use `BOTH` or `SMS` if you also deliver by SMS).
- **Auto-create OTP credential**: **On**.
- **Use Deferred User**: **Off**. Deferred voters take their address from the
authentication session and never need a stored credential, so this option does
not apply to them.

![Auto-create OTP credential enabled](./assets/otp_auto_credential_config.png)

Save the dialog. No voter re-import or edit is required, and no credential
enrolment required action needs to be assigned.

## Step 3: the voter signs in

The voter signs in with their username and password as usual:

![Voter login form](./assets/otp_auto_credential_voter_login.png)

Because the voter has an email address configured, the OTP step now runs on this
first login and the code is emailed to them:

![OTP prompt showing the masked email address](./assets/otp_auto_credential_voter_otp.png)

Entering the emailed code completes the login.

In the development environment the message is written to the Keycloak log rather
than sent, so you can read the code with:

```bash
docker logs keycloak --since 2m 2>&1 | grep -A6 "Sending dummy email"
```

## Step 4: verify the credential was created

Open the voter in **Users → Credentials**. The `message-otp` credential now exists
alongside the password, created at the moment of that first login:

![Voter credentials including message-otp](./assets/otp_auto_credential_user_credentials.png)

The credential is created once. Later logins reuse it, so the OTP step keeps
working even if the option is turned off afterwards.

## Behaviour summary

| Situation | Option off | Option on |
| --- | --- | --- |
| Voter with email or mobile, no stored credential | OTP step skipped | Credential created, OTP sent |
| Voter with a stored `message-otp` credential | OTP sent | OTP sent |
| Voter with neither email nor mobile | OTP step skipped | OTP step skipped |
| Deferred voter (**Use Deferred User** on) | Address taken from the authentication session | Unchanged, no credential stored |

## Notes

- The option only decides whether the voter is *offered* the OTP step. It does not
weaken the check itself: the emailed code is still generated, delivered, and
validated exactly as before.
- A voter without an email address and without a mobile number is never considered
configured, so enabling the option cannot lock anybody into a step they cannot
complete.
- Because the credential is created during login, turning the option on affects
voters gradually as they sign in, rather than in one migration.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions packages/keycloak-extensions/message-otp-authenticator/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<version>10.9.2</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.20.0</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,33 +367,53 @@ public boolean requiresUser() {
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
log.info("configuredFor() called");
MessageOTPCredentialProvider provider = getCredentialProvider(session);
if (provider == null || !provider.isConfiguredFor(realm, user, getType(session))) {
return false;
}

Optional<AuthenticatorConfigModel> config = Utils.getConfig(realm);
Map<String, String> configMap =
MessageOTPAuthenticatorFactory.getConfigMap(config.orElse(null));
boolean deferredUser = "true".equals(configMap.get(Utils.DEFERRED_USER_ATTRIBUTE));

// If no configuration is found, fall back to default behavior
if (!config.isPresent() && user != null) {
return user.getFirstAttribute(MOBILE_NUMBER_FIELD) != null;
}
boolean deferredUser =
"true".equals(config.get().getConfig().get(Utils.DEFERRED_USER_ATTRIBUTE));
String mobileNumber = null;
String emailAddress = null;

if (deferredUser) {
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
String mobileNumberAttribute = config.get().getConfig().get(Utils.TEL_USER_ATTRIBUTE);
String mobileNumberAttribute = configMap.get(Utils.TEL_USER_ATTRIBUTE);
mobileNumber = authSession.getAuthNote(mobileNumberAttribute);
emailAddress = authSession.getAuthNote("email");
} else if (user != null) {
mobileNumber = Utils.getMobile(config.get(), user);
emailAddress = user.getEmail();
mobileNumber = Utils.getMobile(config.orElse(null), user);
emailAddress = config.isPresent() ? user.getEmail() : null;
}
boolean hasOtpAddress = mobileNumber != null || emailAddress != null;

// In deferred mode the OTP address comes from the auth session notes (set during
// deferred registration/login), not from a stored credential, so a
// MessageOTPCredential must not be required. Otherwise, deferred users (which
// never get a credential created for them) would silently be filtered out of the
// authentication selection list, failing the flow with `invalid_user_credentials`.
if (!deferredUser) {
if (user == null) {
return false;
}
MessageOTPCredentialProvider provider = getCredentialProvider(session);
if (provider == null) {
return false;
}
if (!provider.isConfiguredFor(realm, user, getType(session))) {
// If enabled, automatically create the message-otp credential for users
// that have a mobile phone number or email address configured, so that
// imported/edited voters don't need a credential enrollment required action.
boolean autoCreateCredential =
"true".equals(configMap.get(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE));
if (!autoCreateCredential || !hasOtpAddress) {
return false;
}
log.info("Auto-creating MessageOTPCredential for user with configured mobile/email");
provider.createCredential(
realm, user, MessageOTPCredentialModel.create(/* isSetup= */ true));
}
}

return mobileNumber != null || emailAddress != null;
return hasOtpAddress;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,14 @@ public List<ProviderConfigProperty> getConfigProperties() {
"If enabled, there won't be a need to have a valid user when using this authenticator",
ProviderConfigProperty.BOOLEAN_TYPE,
"false"),
new ProviderConfigProperty(
Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE,
"Auto-create OTP credential",
"If enabled, the message-otp credential is automatically created during login for"
+ " non-deferred users that have a mobile phone number or email address configured,"
+ " so that no credential enrollment required action is needed.",
ProviderConfigProperty.BOOLEAN_TYPE,
"false"),
new ProviderConfigProperty(
Utils.OTL_RESTORED_AUTH_NOTES_ATTRIBUTE,
"Comma Separated Names of the Auth Notes to Restore",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ public class Utils {
public final String TEL_USER_ATTRIBUTE = "telUserAttribute";
public final String MESSAGE_COURIER_ATTRIBUTE = "messageCourierAttribute";
public final String DEFERRED_USER_ATTRIBUTE = "deferredUserAttribute";
public final String AUTO_CREATE_CREDENTIAL_ATTRIBUTE = "autoCreateCredentialAttribute";
public final String OTL_RESTORED_AUTH_NOTES_ATTRIBUTE = "otlRestoredAuthNotesAttribute";

public final String SEND_CODE_SMS_I18N_KEY = "messageOtp.sendCode.sms.text";
Expand Down
Loading
Loading