Skip to content
Open
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
Expand Up @@ -40,5 +40,102 @@ Once configured, the Template becomes available for its associated Report Types
- For document templates, ensure any required assets (logos, images) are accessible and correctly referenced.
- If your system supports previewing or templating languages (e.g., handlebars, Liquid), include sample data to verify rendering.

> **Note:** For further guidance on template fields or syntax, refer to the Reports section of the guide where Template usage in report configuration is detailed. ```
## Voter variables in notification templates

Email and SMS Handlebars templates can use these standard voter variables:

- `user.first_name`
- `user.last_name`
- `user.username`
- `user.email`

Custom Keycloak user attributes are also available. The first value is exposed
as `user.<attribute>`. The complete value list remains available as
`user.attributes.<attribute>`. Standard variables take precedence if a custom
attribute uses the same name. The `attributes` name is reserved for the complete
attribute map. Empty custom value lists are present only under `user.attributes`.

Dot notation works for simple names such as `dateOfBirth`. Use Handlebars
`lookup` for names containing dots or dashes. For example:

```handlebars
{{lookup user "sequent.read-only.mobile-number"}}
{{#each (lookup user.attributes "sequent.read-only.mobile-number")}}{{this}} {{/each}}
```

For example, a `reference` attribute with values `ABC-123` and `legacy-456` can
be rendered as follows:

```handlebars
Primary reference: {{user.reference}}
All references: {{#each user.attributes.reference}}{{this}} {{/each}}
```

### Prefilled voting links

Voting Portal `/login` and `/enroll` links accept up to five prefilled fields
named `login_hint__<field>`. Field names may contain letters, numbers, `.`, `_`,
and `-`; names are limited to 128 characters and values to 255 characters.

Use the `url_encode` helper around every dynamic query value. Keep the parameter
names and URL structure static:

```handlebars
https://vote.example/tenant/TENANT_ID/event/EVENT_ID/login?login_hint__username={{url_encode user.username}}&login_hint__reference={{url_encode user.reference}}
```

```handlebars
https://vote.example/tenant/TENANT_ID/event/EVENT_ID/enroll?login_hint__username={{url_encode user.username}}&login_hint__dateOfBirth={{url_encode user.dateOfBirth}}
```

The Voting Portal removes accepted hint parameters from its visible URL before
redirecting to Keycloak. Invalid, duplicate, or over-limit hint sets are rejected
as a whole.

#### Per-field prefill policy

Each registration field decides how it accepts a prefilled value through the
`loginHintPrefillPolicy` annotation of its Keycloak user profile attribute:

| Policy | Behaviour |
| --- | --- |
| `EDITABLE` | Prefill the field and let the voter change the value. Applied when the annotation is absent. |
| `READ_ONLY` | Prefill the field, render it read-only, and reject the registration if the submitted value was changed. |
| `IGNORE` | Never prefill the field from a voting link. |

Set the annotation in **Realm settings → User profile → Attributes → *(attribute)*
→ Annotations**, for example `loginHintPrefillPolicy` = `READ_ONLY`. An
unrecognised value is treated as `IGNORE`, so a typo never prefills a field.

Credential fields, unmanaged attributes, attributes the voter cannot write, and
attributes rendered as hidden inputs are never prefilled, whatever the policy.

The policy applies to registration forms once the **Sequent: Login hint
registration prefill** action is part of the registration flow.

#### Locking the username on the login page

The login page is not rendered from the user profile, so it cannot read attribute
annotations. Set the realm attribute `loginHintUsernamePolicy` to `READ_ONLY`
(default `EDITABLE`) to render a prefilled username read-only. A username
restored by *remember me* stays editable, so voters can still sign in as somebody
else.

This is a presentation-only lock: it stops a voter from editing the field by
accident, but Keycloak still authenticates whichever username is submitted. That
is the same guarantee as an unprefilled login page — the password decides which
account is entered. Registration is different: there the value is stored, so
`READ_ONLY` is enforced on the server as well.

:::warning
Prefilled values are convenience data, not verified identity claims. They never
bypass authentication, registration validation, or required actions. `READ_ONLY`
stops a voter from changing a prefilled field; it does not make the value
trustworthy, because whoever built the link chose it. Do not include passwords,
tokens, secrets, or sensitive attributes that are not approved for browser URLs
and notification delivery. Percent encoding protects URL structure; it does not
provide confidentiality or authenticity.
:::

> **Note:** For further guidance on template fields or syntax, refer to the Reports section of the guide where Template usage in report configuration is detailed.

Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,66 @@ variables. Upload the objects first, then deploy the Step, beyond, and gitops
configuration changes together. The Windmill error for a missing object names
the configured bucket and key.

## Login hint prefill configuration

Voting Portal notification links can carry bounded `login_hint__<field>` query
parameters on tenant/event `/login` and `/enroll` routes. The portal validates
the complete hint set, removes it from browser history, and appends accepted
values to the URL generated by `keycloak-js`. A `username` hint is also sent as
the standard OIDC `login_hint`.

Keycloak stores the additional authorization-request parameters as client notes.
The voter-enrollment provider accepts at most five hints, with field names
matching `[A-Za-z0-9._-]+`, names no longer than 128 characters, and values no
longer than 255 characters. Any invalid hint rejects the complete set.

### Stock registration flow

In each realm that uses Keycloak's stock registration form:

1. Open **Authentication** and duplicate or edit the active registration flow.
2. Add the **Sequent: Login hint registration prefill** execution.
3. Set its requirement to **Required**.
4. Place it before the execution that creates the registration user.
5. Bind the updated registration flow to the realm.

The action applies hints only while rendering the initial `GET` form. It
intersects them with declarative user-profile metadata and passes only managed,
writable attributes to the form. Submitted `POST` values always take precedence.

### Deferred registration flow

The **Deferred Registration User Profile Creation** execution has a
**Prefill Parameters Policy** setting:

| Value | Behavior |
| --- | --- |
| `IGNORE` | Default. Do not prefill fields from login hints. |
| `ACCEPT` | Prefill managed, writable fields on the initial `GET` form. |

Set `ACCEPT` only in realms where notification-link prefill is an approved
workflow. Password fields, unmanaged or read-only fields, hidden profile
attributes, and the configured verification-status attribute are never
prefilled. Redirect-to-registration flows retain the same authentication session
and therefore require no separate hint configuration.

### Realm template rollout

Update the source realm JSON files under `.devcontainer/keycloak/import/` so new
tenant and election-event realms receive the intended flow configuration. Then
upload the templates using the S3 provisioning process above. Existing realms
are not changed by replacing the default template: update or re-import their
authentication flow configuration separately and verify both direct enrollment
and login-to-registration redirects.

Treat every hint as untrusted, user-editable input. Prefill must not mark email,
phone, identity, eligibility, or other verification state as trusted. Do not log
hint values, and do not place credentials, tokens, or secrets in notification
URLs. URL encoding prevents query-structure injection but does not make values
confidential or authentic. Dynamic attributes with dots or dashes in their names
must be read with the Handlebars `lookup` helper; `attributes` is reserved for
the complete attribute map.

## Tally

### Discarded/Auditable Ballots
Expand Down
1 change: 1 addition & 0 deletions packages/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2026 Sequent Tech <legal@sequentech.io>
//
// SPDX-License-Identifier: AGPL-3.0-only
package sequent.keycloak.conditional_authenticators;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.models.ClientModel;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.mockito.ArgumentCaptor;

class RedirectToRegisterAuthenticatorTest {

@Test
void redirectKeepsTheAuthenticationSessionAndItsLoginHintClientNotes() {
AuthenticationFlowContext context = mock(AuthenticationFlowContext.class);
AuthenticationSessionModel authenticationSession = mock(AuthenticationSessionModel.class);
ClientModel client = mock(ClientModel.class);
RealmModel realm = mock(RealmModel.class);
UriInfo uriInfo = mock(UriInfo.class);
Map<String, String> clientNotes = new LinkedHashMap<>();
clientNotes.put(clientNote("username"), "voter@example.com");

when(context.getUriInfo()).thenReturn(uriInfo);
when(uriInfo.getBaseUri()).thenReturn(URI.create("https://id.example/auth/"));
when(context.getAuthenticationSession()).thenReturn(authenticationSession);
when(authenticationSession.getClient()).thenReturn(client);
when(authenticationSession.getTabId()).thenReturn("same-tab");
when(authenticationSession.getClientNotes()).thenReturn(clientNotes);
when(client.getClientId()).thenReturn("voting-portal");
when(context.getRealm()).thenReturn(realm);
when(realm.getName()).thenReturn("election");

new RedirectToRegisterAuthenticator().authenticate(context);

ArgumentCaptor<Response> responseCaptor = ArgumentCaptor.forClass(Response.class);
verify(context).challenge(responseCaptor.capture());
Response response = responseCaptor.getValue();
String location = response.getLocation().toString();

assertEquals(Response.Status.FOUND.getStatusCode(), response.getStatus());
assertTrue(location.contains("/realms/election/login-actions/registration"));
assertTrue(location.contains("client_id=voting-portal"));
assertTrue(location.contains("tab_id=same-tab"));
assertEquals(Map.of(clientNote("username"), "voter@example.com"), clientNotes);
verify(authenticationSession, never()).setClientNote(anyString(), anyString());
verify(authenticationSession, never()).removeClientNote(anyString());
}

private static String clientNote(String attributeName) {
return AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX
+ "login_hint__"
+ attributeName;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ doLogIn=LOGIN
loginTitle=Login to {0}
loginFooter=Powered by Sequent Tech Inc
invalidConfirmationValue=Confirmation doesn''t match
loginHintReadOnlyFieldModified=This field was prefilled and cannot be changed
system.version=Version:
system.hash=Hash:
backToLogin=BACK TO LOGIN
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only

<#-- Source: https://github.com/keycloak/keycloak/blob/24.0.0/themes/src/main/resources/theme/base/login/user-profile-commons.ftl -->

<#-- True when the attribute was prefilled from a login hint annotated as READ_ONLY,
in which case the voter must not be able to change the submitted value. -->
<#function isLoginHintReadOnly attributeName>
<#return loginHintReadOnlyAttributes?? && loginHintReadOnlyAttributes?seq_contains(attributeName)>
</#function>

<#macro userProfileFormFields>
<#assign currentGroup="">
<#assign readonlyElements = []>
Expand Down Expand Up @@ -183,6 +189,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<input type="<@inputTagType attribute=attribute/>" id="${name}" name="${name}" value="${(value!'')}" class="${properties.kcInputClass!}"
aria-invalid="<#if messagesPerField.existsError('${name}')>true</#if>"
<#if attribute.readOnly>disabled</#if>
<#-- readonly rather than disabled so the locked value is still submitted -->
<#if isLoginHintReadOnly(attribute.name)>readonly</#if>
<#-- Checks for attribute annotations that start with "html-attribute:" and sets them as input attributes -->
<#list attribute.annotations as key, value>
<#if key?starts_with("html-attribute:")>${key[15..]}=${value}</#if>
Expand Down Expand Up @@ -220,6 +228,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<textarea id="${name}" name="${name}" class="${properties.kcInputClass!}"
aria-invalid="<#if messagesPerField.existsError('${name}')>true</#if>"
<#if attribute.readOnly>disabled</#if>
<#if isLoginHintReadOnly(attribute.name)>readonly</#if>
<#if attribute.annotations.inputTypeCols??>cols="${attribute.annotations.inputTypeCols}"</#if>
<#if attribute.annotations.inputTypeRows??>rows="${attribute.annotations.inputTypeRows}"</#if>
<#if attribute.annotations.inputTypeMaxlength??>maxlength="${attribute.annotations.inputTypeMaxlength}"</#if>
Expand All @@ -229,7 +238,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<#macro selectTag attribute name values>
<select id="${name}" name="${name}" class="${properties.kcInputClass!}"
aria-invalid="<#if messagesPerField.existsError('${name}')>true</#if>"
<#if attribute.readOnly>disabled</#if>
<#if attribute.readOnly || isLoginHintReadOnly(attribute.name)>disabled</#if>
<#if attribute.annotations.inputType=='multiselect'>multiple</#if>
<#if attribute.annotations.inputTypeSize??>size="${attribute.annotations.inputTypeSize}"</#if>
<#if attribute.annotations.filterSelectAttribute??>onchange="filterSelectAttribute(event, '${attribute.annotations.filterSelectAttribute}')"</#if>
Expand All @@ -251,6 +260,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</#list>

</select>
<#-- A disabled select submits nothing, so mirror the locked value in a hidden input -->
<@loginHintLockedValues attribute=attribute name=name values=values/>
</#macro>

<#macro loginHintLockedValues attribute name values>
<#if isLoginHintReadOnly(attribute.name)>
<#list values as value>
<input type="hidden" name="${name}" value="${value}"/>
</#list>
</#if>
</#macro>

<#macro inputTagSelects attribute name values>
Expand Down Expand Up @@ -278,7 +297,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="${classDiv}">
<input type="${inputType}" id="${name}-${option}" name="${name}" value="${option}" class="${classInput}"
aria-invalid="<#if messagesPerField.existsError('${name}')>true</#if>"
<#if attribute.readOnly>disabled</#if>
<#if attribute.readOnly || isLoginHintReadOnly(attribute.name)>disabled</#if>
<#if values?seq_contains(option)>checked</#if>
<#if attribute.annotations.disableAttribute??>onclick="readOnlyElementById(event, '${option}')"</#if>
<#if attribute.annotations.disableElement??>onclick="disableElementById(event, '${option}')"</#if>
Expand All @@ -292,6 +311,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<#assign disabledElements += [{"id":"${option}","checked":"${values?seq_contains(option)?c}"}]>
</#if>
</#list>
<#-- Disabled radios and checkboxes submit nothing, so mirror the locked value -->
<@loginHintLockedValues attribute=attribute name=name values=values/>
</#macro>

<#macro selectOptionLabelText attribute option>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<#if section = "header">
${msg("loginAccountTitle")}
<#elseif section = "form">
<#-- The login page is not rendered from the user profile, so the username policy is a
realm attribute rather than the loginHintPrefillPolicy attribute annotation used by
the registration forms. A remembered username stays editable so the voter can still
sign in as somebody else. -->
<#assign usernamePrefilled = (login.username!'')?has_content && !login.rememberMe??>
<#assign usernameReadOnly = usernamePrefilled
&& (realm.attributes['loginHintUsernamePolicy']!'EDITABLE') == 'READ_ONLY'>
<div id="kc-form">
<div id="kc-form-wrapper">
<#if realm.password>
Expand All @@ -20,7 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="${properties.kcFormGroupClass!}">
<label for="username" class="${properties.kcLabelClass!}"><#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if></label>

<input tabindex="1" id="username" class="${properties.kcInputClass!}" name="username" type="text" autofocus autocomplete="<#if structuredCredential>username<#else>off</#if>"
<#-- readonly rather than disabled so the locked value is still submitted -->
<input tabindex="1" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')}" type="text" autofocus autocomplete="<#if structuredCredential>username<#else>off</#if>"
<#if usernameReadOnly>readonly</#if>
<#if structuredCredentialHasError || credentialFieldError>aria-invalid="true"</#if>
/>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,24 @@ void structuredCredentialDoesNotMaskOperationalGlobalErrors() throws IOException
template.contains(
"<#assign structuredCredentialHasError = structuredCredential && credentialFieldError>"));
}

@Test
void prefillsTheUsernameFromTheLoginHint() throws IOException {
String template = Files.readString(LOGIN_TEMPLATE);

assertTrue(template.contains("name=\"username\" value=\"${(login.username!'')}\""));
}

@Test
void locksThePrefilledUsernameWhenTheRealmPolicyIsReadOnly() throws IOException {
String template = Files.readString(LOGIN_TEMPLATE);

assertTrue(
template.contains(
"<#assign usernamePrefilled = (login.username!'')?has_content && !login.rememberMe??>"));
assertTrue(
template.contains(
"(realm.attributes['loginHintUsernamePolicy']!'EDITABLE') == 'READ_ONLY'"));
assertTrue(template.contains("<#if usernameReadOnly>readonly</#if>"));
}
}
Loading
Loading