diff --git a/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/13-02-election_management_election-event_templates.md b/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/13-02-election_management_election-event_templates.md index f7db96a16c4..3f189dd9048 100644 --- a/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/13-02-election_management_election-event_templates.md +++ b/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/13-02-election_management_election-event_templates.md @@ -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.`. The complete value list remains available as +`user.attributes.`. 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 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. diff --git a/docs/docusaurus/docs/07-developers/08-windmill/developers_windmill.md b/docs/docusaurus/docs/07-developers/08-windmill/developers_windmill.md index c21f7cc50c3..bcb6b1dd8b4 100644 --- a/docs/docusaurus/docs/07-developers/08-windmill/developers_windmill.md +++ b/docs/docusaurus/docs/07-developers/08-windmill/developers_windmill.md @@ -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__` 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 diff --git a/packages/Cargo.lock b/packages/Cargo.lock index 4f7d76a5419..aa6dc40db83 100644 --- a/packages/Cargo.lock +++ b/packages/Cargo.lock @@ -8622,6 +8622,7 @@ dependencies = [ "tracing-log", "tracing-subscriber", "tracing-tree", + "urlencoding", "uuid", "warp", "wasm-bindgen", diff --git a/packages/keycloak-extensions/conditional-authenticators/src/test/java/sequent/keycloak/conditional_authenticators/RedirectToRegisterAuthenticatorTest.java b/packages/keycloak-extensions/conditional-authenticators/src/test/java/sequent/keycloak/conditional_authenticators/RedirectToRegisterAuthenticatorTest.java new file mode 100644 index 00000000000..54ef4d8f0e6 --- /dev/null +++ b/packages/keycloak-extensions/conditional-authenticators/src/test/java/sequent/keycloak/conditional_authenticators/RedirectToRegisterAuthenticatorTest.java @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech +// +// 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 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 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; + } +} diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_en.properties b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_en.properties index 6d2b1f663ad..2be06a73784 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_en.properties +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_en.properties @@ -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 diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/user-profile-commons.ftl b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/user-profile-commons.ftl index 1e32dec0c42..dda5985b6bf 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/user-profile-commons.ftl +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/user-profile-commons.ftl @@ -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)> + + <#macro userProfileFormFields> <#assign currentGroup=""> <#assign readonlyElements = []> @@ -183,6 +189,8 @@ SPDX-License-Identifier: AGPL-3.0-only disabled + <#-- readonly rather than disabled so the locked value is still submitted --> + <#if isLoginHintReadOnly(attribute.name)>readonly <#-- 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} @@ -220,6 +228,7 @@ SPDX-License-Identifier: AGPL-3.0-only