From c398861466c6aba40287c3374b2ec918ea0c379b Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 09:24:55 +0200 Subject: [PATCH 01/13] =?UTF-8?q?=E2=9C=A8=20Add=20voting=20portal=20login?= =?UTF-8?q?=20hint=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/voting-portal/jest.config.cjs | 21 +++++ packages/voting-portal/package.json | 2 + .../voting-portal/src/routes/ErrorPage.tsx | 14 +++ .../src/services/VotingPortalError.ts | 1 + packages/voting-portal/src/translations/en.ts | 3 + .../src/utils/loginHints.test.ts | 91 +++++++++++++++++++ .../voting-portal/src/utils/loginHints.ts | 85 +++++++++++++++++ packages/yarn.lock | 2 +- 8 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 packages/voting-portal/jest.config.cjs create mode 100644 packages/voting-portal/src/utils/loginHints.test.ts create mode 100644 packages/voting-portal/src/utils/loginHints.ts diff --git a/packages/voting-portal/jest.config.cjs b/packages/voting-portal/jest.config.cjs new file mode 100644 index 00000000000..7dd2e6fec2c --- /dev/null +++ b/packages/voting-portal/jest.config.cjs @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +/** @type {import('jest').Config} */ +module.exports = { + testEnvironment: "node", + testMatch: ["/src/**/*.test.ts"], + transform: { + "^.+\\.(t|j)sx?$": [ + "@swc/jest", + { + jsc: { + parser: {syntax: "typescript", tsx: true}, + target: "es2022", + }, + module: {type: "commonjs"}, + }, + ], + }, +} diff --git a/packages/voting-portal/package.json b/packages/voting-portal/package.json index a2719d83aea..922138bd559 100644 --- a/packages/voting-portal/package.json +++ b/packages/voting-portal/package.json @@ -40,6 +40,7 @@ "path-to-regexp": "^0.1.12" }, "scripts": { + "test": "jest --config jest.config.cjs", "start": "PORT=3000 NODE_OPTIONS='--openssl-legacy-provider --max-old-space-size=4096' webpack serve --config webpack.config.cjs --mode development", "build": "NODE_OPTIONS='--openssl-legacy-provider --max-old-space-size=4096' webpack --config webpack.config.cjs --mode production", "prettify": "prettier --check . --ignore-path .prettierignore", @@ -67,6 +68,7 @@ ] }, "devDependencies": { + "@swc/jest": "^0.2.39", "@graphql-codegen/cli": "6.0.0", "@graphql-codegen/client-preset": "^5.0.0", "@graphql-codegen/introspection": "5.0.0", diff --git a/packages/voting-portal/src/routes/ErrorPage.tsx b/packages/voting-portal/src/routes/ErrorPage.tsx index bffd7608ba6..31bb4388d86 100644 --- a/packages/voting-portal/src/routes/ErrorPage.tsx +++ b/packages/voting-portal/src/routes/ErrorPage.tsx @@ -78,6 +78,20 @@ export function ErrorPage() { ) + } else if ( + error instanceof VotingPortalError && + error.type === VotingPortalErrorType.INVALID_LOGIN_HINT_PARAMETERS + ) { + content = ( + <> + + {t("errors.page.invalidLoginHintParametersTitle")} + + + {t("errors.page.invalidLoginHintParametersMessage")} + + + ) } else if (isErrorType) { content = ( <> diff --git a/packages/voting-portal/src/services/VotingPortalError.ts b/packages/voting-portal/src/services/VotingPortalError.ts index 32bfa67b935..b8dc63397cc 100644 --- a/packages/voting-portal/src/services/VotingPortalError.ts +++ b/packages/voting-portal/src/services/VotingPortalError.ts @@ -11,6 +11,7 @@ export enum VotingPortalErrorType { NO_BALLOT_STYLE = "NO_BALLOT_STYLE", INCONSISTENT_HASH = "INCONSISTENT_HASH", CERT_AUTH_FAILED = "CERT_AUTH_FAILED", + INVALID_LOGIN_HINT_PARAMETERS = "INVALID_LOGIN_HINT_PARAMETERS", } export enum ElectionScreenErrorType { diff --git a/packages/voting-portal/src/translations/en.ts b/packages/voting-portal/src/translations/en.ts index 4a5e2635203..e044c8b5bac 100644 --- a/packages/voting-portal/src/translations/en.ts +++ b/packages/voting-portal/src/translations/en.ts @@ -342,6 +342,9 @@ const englishTranslation = { certAuthFailedTitle: "Certificate Authentication Failed", certAuthFailedMessage: "Your certificate could not be verified. Please check that you are using a valid voter certificate and try again.", + invalidLoginHintParametersTitle: "Invalid voting link", + invalidLoginHintParametersMessage: + "This voting link contains invalid login information. Request a new link and try again.", }, }, materials: { diff --git a/packages/voting-portal/src/utils/loginHints.test.ts b/packages/voting-portal/src/utils/loginHints.test.ts new file mode 100644 index 00000000000..9a87db5c209 --- /dev/null +++ b/packages/voting-portal/src/utils/loginHints.test.ts @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +import { + InvalidLoginHintsError, + MAX_LOGIN_HINT_COUNT, + MAX_LOGIN_HINT_NAME_LENGTH, + MAX_LOGIN_HINT_VALUE_LENGTH, + appendLoginHints, + parseLoginHints, +} from "./loginHints" + +describe("parseLoginHints", () => { + it("returns no hints and preserves unrelated query parameters", () => { + expect(parseLoginHints("?lang=en&kiosk")).toEqual({ + hints: {}, + remainingSearch: "?lang=en&kiosk=", + }) + }) + + it("extracts namespaced fields and removes only those fields", () => { + expect( + parseLoginHints( + "?lang=es&login_hint__username=foo%2Bbar%40example.com&login_hint__dateOfBirth=2000-01-01" + ) + ).toEqual({ + hints: { + username: "foo+bar@example.com", + dateOfBirth: "2000-01-01", + }, + remainingSearch: "?lang=es", + }) + }) + + it("accepts the maximum number and size of fields", () => { + const parameters = new URLSearchParams() + const name = "a".repeat(MAX_LOGIN_HINT_NAME_LENGTH) + parameters.set(`login_hint__${name}`, "v".repeat(MAX_LOGIN_HINT_VALUE_LENGTH)) + for (let index = 1; index < MAX_LOGIN_HINT_COUNT; index += 1) { + parameters.set(`login_hint__field${index}`, `value${index}`) + } + + expect(Object.keys(parseLoginHints(`?${parameters}`).hints)).toHaveLength( + MAX_LOGIN_HINT_COUNT + ) + }) + + it.each([ + ["an empty field name", "?login_hint__=value"], + ["an empty value", "?login_hint__username="], + ["an invalid field name", "?login_hint__first%20name=value"], + [ + "an overlong field name", + `?login_hint__${"a".repeat(MAX_LOGIN_HINT_NAME_LENGTH + 1)}=value`, + ], + [ + "an overlong value", + `?login_hint__username=${"a".repeat(MAX_LOGIN_HINT_VALUE_LENGTH + 1)}`, + ], + [ + "too many fields", + `?${Array.from( + {length: MAX_LOGIN_HINT_COUNT + 1}, + (_, index) => `login_hint__field${index}=value${index}` + ).join("&")}`, + ], + ["a duplicate field", "?login_hint__username=first&login_hint__username=second"], + ])("rejects %s", (_description, search) => { + expect(() => parseLoginHints(search)).toThrow(InvalidLoginHintsError) + }) +}) + +describe("appendLoginHints", () => { + it("preserves OIDC parameters and percent-encodes hint names and values", () => { + const result = appendLoginHints( + "https://id.example/authorize?state=oidc-state&login_hint=user%40example.com", + { + username: "user@example.com", + reference: "a&b=c % value", + } + ) + const resultUrl = new URL(result) + + expect(resultUrl.searchParams.get("state")).toBe("oidc-state") + expect(resultUrl.searchParams.get("login_hint")).toBe("user@example.com") + expect(resultUrl.searchParams.get("login_hint__username")).toBe("user@example.com") + expect(resultUrl.searchParams.get("login_hint__reference")).toBe("a&b=c % value") + expect(resultUrl.searchParams.get("b")).toBeNull() + }) +}) diff --git a/packages/voting-portal/src/utils/loginHints.ts b/packages/voting-portal/src/utils/loginHints.ts new file mode 100644 index 00000000000..c0f8b648647 --- /dev/null +++ b/packages/voting-portal/src/utils/loginHints.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +export const LOGIN_HINT_PREFIX = "login_hint__" +export const MAX_LOGIN_HINT_COUNT = 5 +export const MAX_LOGIN_HINT_NAME_LENGTH = 128 +export const MAX_LOGIN_HINT_VALUE_LENGTH = 255 + +const LOGIN_HINT_NAME_PATTERN = /^[A-Za-z0-9._-]+$/ + +export type LoginHints = Record + +export type ParsedLoginHints = { + hints: LoginHints + remainingSearch: string +} + +export class InvalidLoginHintsError extends Error { + constructor() { + super("Invalid login hint parameters") + this.name = "InvalidLoginHintsError" + } +} + +const validateLoginHint = (name: string, value: string): void => { + if ( + !name || + name.length > MAX_LOGIN_HINT_NAME_LENGTH || + !LOGIN_HINT_NAME_PATTERN.test(name) || + !value.trim() || + value.length > MAX_LOGIN_HINT_VALUE_LENGTH + ) { + throw new InvalidLoginHintsError() + } +} + +export const parseLoginHints = (search: string): ParsedLoginHints => { + const searchParams = new URLSearchParams(search) + const hints: LoginHints = {} + + for (const [parameterName, value] of searchParams.entries()) { + if (!parameterName.startsWith(LOGIN_HINT_PREFIX)) { + continue + } + + const hintName = parameterName.slice(LOGIN_HINT_PREFIX.length) + validateLoginHint(hintName, value) + + if (Object.hasOwn(hints, hintName)) { + throw new InvalidLoginHintsError() + } + + hints[hintName] = value + if (Object.keys(hints).length > MAX_LOGIN_HINT_COUNT) { + throw new InvalidLoginHintsError() + } + } + + for (const hintName of Object.keys(hints)) { + searchParams.delete(`${LOGIN_HINT_PREFIX}${hintName}`) + } + + const remainingSearch = searchParams.toString() + return { + hints, + remainingSearch: remainingSearch ? `?${remainingSearch}` : "", + } +} + +export const appendLoginHints = (url: string, hints: LoginHints): string => { + const result = new URL(url) + const hintEntries = Object.entries(hints) + + if (hintEntries.length > MAX_LOGIN_HINT_COUNT) { + throw new InvalidLoginHintsError() + } + + for (const [name, value] of hintEntries) { + validateLoginHint(name, value) + result.searchParams.set(`${LOGIN_HINT_PREFIX}${name}`, value) + } + + return result.toString() +} diff --git a/packages/yarn.lock b/packages/yarn.lock index b7e1458e800..39837e2e00a 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -5386,7 +5386,7 @@ resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== -"@swc/jest@^0.2.23": +"@swc/jest@^0.2.23", "@swc/jest@^0.2.39": version "0.2.39" resolved "https://registry.yarnpkg.com/@swc/jest/-/jest-0.2.39.tgz#482bee0adb0726fab1487a4f902a278ec563a6b7" integrity sha512-eyokjOwYd0Q8RnMHri+8/FS1HIrIUKK/sRrFp8c1dThUOfNeCWbLmBP1P5VsKdvmkd25JaH+OKYwEYiAYg9YAA== From 12363fa80507d181f0b11b097bbb5465e38a7bda Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 09:31:01 +0200 Subject: [PATCH 02/13] =?UTF-8?q?=E2=9C=A8=20Forward=20login=20hints=20thr?= =?UTF-8?q?ough=20Keycloak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/voting-portal/src/App.tsx | 53 +++++++++++++++++-- .../src/providers/AuthContextProvider.tsx | 51 +++++++++++++++--- .../src/utils/loginHints.test.ts | 5 +- 3 files changed, 97 insertions(+), 12 deletions(-) diff --git a/packages/voting-portal/src/App.tsx b/packages/voting-portal/src/App.tsx index fc5150763f5..f67302ef060 100644 --- a/packages/voting-portal/src/App.tsx +++ b/packages/voting-portal/src/App.tsx @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-only -import React, {useEffect, useContext, useMemo, useCallback} from "react" +import React, {useEffect, useContext, useMemo, useCallback, useState} from "react" import {Outlet, ScrollRestoration, useLocation, useParams} from "react-router-dom" import {styled} from "@mui/material/styles" import {Footer, Header, PageBanner} from "@sequentech/ui-essentials" @@ -33,6 +33,7 @@ import WatermarkBackground from "./components/WaterMark/Watermark" import SequentLogo from "@sequentech/ui-essentials/public/Sequent_logo.svg" import BlankLogoImg from "@sequentech/ui-essentials/public/blank_logo.svg" import {useElectionClassName} from "./hooks/useElectionClassName" +import {InvalidLoginHintsError, parseLoginHints} from "./utils/loginHints" interface ElectionEventConfigDocument { id: string tenant_id: string @@ -132,6 +133,27 @@ const App = () => { const location = useLocation() const {tenantId, eventId} = useParams() const {isAuthenticated, setTenantEvent} = useContext(AuthContext) + const [loginHintRequest] = useState(() => { + const acceptsLoginHints = ["/login", "/enroll"].some((suffix) => + location.pathname.endsWith(suffix) + ) + + try { + const parsed = acceptsLoginHints + ? parseLoginHints(location.search) + : {hints: {}, remainingSearch: location.search} + return {...parsed, pathname: location.pathname, hash: location.hash} + } catch (error) { + if (error instanceof InvalidLoginHintsError) { + throw new VotingPortalError(VotingPortalErrorType.INVALID_LOGIN_HINT_PARAMETERS) + } + throw error + } + }) + const loginHintsForCurrentRoute = useMemo( + () => (loginHintRequest.pathname === location.pathname ? loginHintRequest.hints : {}), + [location.pathname, loginHintRequest] + ) const electionIds = useAppSelector(selectElectionIds) const ballotStyleElectionIds = useAppSelector(selectBallotStyleElectionIds) @@ -144,6 +166,22 @@ const App = () => { useElectionClassName() + useEffect(() => { + if (Object.keys(loginHintRequest.hints).length === 0) { + return + } + + // Keep validated hints in memory while removing PII from browser history and redirect URIs. + navigate( + { + pathname: loginHintRequest.pathname, + search: loginHintRequest.remainingSearch, + hash: loginHintRequest.hash, + }, + {replace: true} + ) + }, [loginHintRequest, navigate]) + useEffect(() => { if (location.pathname === "/") { throw new VotingPortalError(VotingPortalErrorType.NO_ELECTION_EVENT) @@ -185,12 +223,19 @@ const App = () => { ? languageConf.default_language_code : undefined - setTenantEvent(tenantId, eventId, mode, defaultLocale) + setTenantEvent(tenantId, eventId, mode, defaultLocale, loginHintsForCurrentRoute) } catch (error) { console.error("Error loading election event config:", error) - setTenantEvent(tenantId, eventId, mode, undefined) + setTenantEvent(tenantId, eventId, mode, undefined, loginHintsForCurrentRoute) } - }, [tenantId, eventId, electionEventConfigUrl, location.pathname, setTenantEvent]) + }, [ + tenantId, + eventId, + electionEventConfigUrl, + location.pathname, + loginHintsForCurrentRoute, + setTenantEvent, + ]) useEffect(() => { if (isAuthenticated) { diff --git a/packages/voting-portal/src/providers/AuthContextProvider.tsx b/packages/voting-portal/src/providers/AuthContextProvider.tsx index c7ba3aba13c..fc5ed38e5c4 100644 --- a/packages/voting-portal/src/providers/AuthContextProvider.tsx +++ b/packages/voting-portal/src/providers/AuthContextProvider.tsx @@ -10,6 +10,7 @@ import {SettingsContext} from "./SettingsContextProvider" import {getLanguageFromURL} from "../utils/queryParams" import {useTranslation} from "react-i18next" import {IPermissions} from "../types/keycloak" +import {appendLoginHints, LoginHints} from "../utils/loginHints" /** * AuthContextValues defines the structure for the default values of the {@link AuthContext}. @@ -66,7 +67,8 @@ export interface AuthContextValues { tenantId: string, eventId: string, authType?: "register" | "login", - defaultLocale?: string + defaultLocale?: string, + loginHints?: LoginHints ) => void /** @@ -100,7 +102,13 @@ const defaultAuthContextValues: AuthContextValues = { keycloakAccessToken: undefined, logout: () => {}, getExpiry: () => undefined, - setTenantEvent: (_tenantId: string, _eventId: string, _authType, _defaultLocale) => {}, + setTenantEvent: ( + _tenantId: string, + _eventId: string, + _authType, + _defaultLocale, + _loginHints + ) => {}, hasRole: () => false, isKiosk: () => false, openProfileLink: () => new Promise(() => undefined), @@ -144,6 +152,7 @@ const AuthContextProvider = (props: AuthContextProviderProps) => { const [eventId, setEventId] = useState(null) const [authType, setAuthType] = useState<"register" | "login" | null>(null) const [defaultLocale, setDefaultLocale] = useState(undefined) + const [loginHints, setLoginHints] = useState({}) const {i18n} = useTranslation() @@ -294,15 +303,41 @@ const AuthContextProvider = (props: AuthContextProviderProps) => { if (authType === "register") { const baseUrl = window.location.origin + window.location.pathname const queryString = window.location.search - - return await keycloak.register({ + const registerOptions = { ...keycloakInitOptions, // after successful enrollment, we should redirect to login redirectUri: baseUrl.endsWith("/enroll") ? baseUrl.replace(/\/enroll$/, "/login") + queryString : undefined, - }) + loginHint: loginHints.username, + } + + if (Object.keys(loginHints).length > 0) { + window.location.assign( + appendLoginHints( + keycloak.createRegisterUrl(registerOptions), + loginHints + ) + ) + return + } + + return await keycloak.register(registerOptions) } else { + if (Object.keys(loginHints).length > 0) { + window.location.assign( + appendLoginHints( + keycloak.createLoginUrl({ + ...keycloakInitOptions, + // Stock username forms only understand the standard OIDC hint. + loginHint: loginHints.username, + }), + loginHints + ) + ) + return + } + return await keycloak.login(keycloakInitOptions) } } @@ -326,7 +361,7 @@ const AuthContextProvider = (props: AuthContextProviderProps) => { if (keycloak && !isAuthenticated && !isKeycloakInitialized) { initializeKeycloak() } - }, [keycloak, isAuthenticated, isKeycloakInitialized, authType, defaultLocale]) + }, [keycloak, isAuthenticated, isKeycloakInitialized, authType, defaultLocale, loginHints]) /** * Returns true only if the JWT has gold permissions and the JWT @@ -395,12 +430,14 @@ const AuthContextProvider = (props: AuthContextProviderProps) => { tenantId: string, eventId: string, authType?: "register" | "login", - defaultLocale?: string + defaultLocale?: string, + initialLoginHints?: LoginHints ) => { setTenantId(tenantId) setEventId(eventId) setDefaultLocale(defaultLocale) authType && setAuthType(authType) + setLoginHints(initialLoginHints ?? {}) } const getRedirectUrl = (redirectUrl?: string) => { diff --git a/packages/voting-portal/src/utils/loginHints.test.ts b/packages/voting-portal/src/utils/loginHints.test.ts index 9a87db5c209..1e2e83924bf 100644 --- a/packages/voting-portal/src/utils/loginHints.test.ts +++ b/packages/voting-portal/src/utils/loginHints.test.ts @@ -74,7 +74,7 @@ describe("parseLoginHints", () => { describe("appendLoginHints", () => { it("preserves OIDC parameters and percent-encodes hint names and values", () => { const result = appendLoginHints( - "https://id.example/authorize?state=oidc-state&login_hint=user%40example.com", + "https://id.example/authorize?state=oidc-state&nonce=oidc-nonce&code_challenge=pkce-challenge&redirect_uri=https%3A%2F%2Fvote.example%2Flogin&login_hint=user%40example.com", { username: "user@example.com", reference: "a&b=c % value", @@ -83,6 +83,9 @@ describe("appendLoginHints", () => { const resultUrl = new URL(result) expect(resultUrl.searchParams.get("state")).toBe("oidc-state") + expect(resultUrl.searchParams.get("nonce")).toBe("oidc-nonce") + expect(resultUrl.searchParams.get("code_challenge")).toBe("pkce-challenge") + expect(resultUrl.searchParams.get("redirect_uri")).toBe("https://vote.example/login") expect(resultUrl.searchParams.get("login_hint")).toBe("user@example.com") expect(resultUrl.searchParams.get("login_hint__username")).toBe("user@example.com") expect(resultUrl.searchParams.get("login_hint__reference")).toBe("a&b=c % value") From 56f10afde535db40ac562065fde20dd1704d0504 Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 10:30:25 +0200 Subject: [PATCH 03/13] =?UTF-8?q?=E2=9C=A8=20Prefill=20stock=20Keycloak=20?= =?UTF-8?q?registration=20forms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../voter-enrollment/pom.xml | 6 + .../voter_enrollment/LoginHintPrefill.java | 76 ++++++++++ .../LoginHintRegistrationPrefill.java | 142 ++++++++++++++++++ .../LoginHintPrefillTest.java | 141 +++++++++++++++++ 4 files changed, 365 insertions(+) create mode 100644 packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/LoginHintPrefill.java create mode 100644 packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/LoginHintRegistrationPrefill.java create mode 100644 packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/LoginHintPrefillTest.java diff --git a/packages/keycloak-extensions/voter-enrollment/pom.xml b/packages/keycloak-extensions/voter-enrollment/pom.xml index ae6fe6d1fbf..00c73e4145e 100644 --- a/packages/keycloak-extensions/voter-enrollment/pom.xml +++ b/packages/keycloak-extensions/voter-enrollment/pom.xml @@ -31,6 +31,12 @@ SPDX-License-Identifier: AGPL-3.0-only 6.0.3 test + + org.mockito + mockito-core + 5.19.0 + test + diff --git a/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/LoginHintPrefill.java b/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/LoginHintPrefill.java new file mode 100644 index 00000000000..e1f7fe60b03 --- /dev/null +++ b/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/LoginHintPrefill.java @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech +// +// SPDX-License-Identifier: AGPL-3.0-only +package sequent.keycloak.voter_enrollment; + +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.keycloak.authentication.forms.RegistrationPage; +import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; +import org.keycloak.userprofile.Attributes; + +final class LoginHintPrefill { + + static final String HINT_PREFIX = "login_hint__"; + static final int MAX_HINT_COUNT = 5; + static final int MAX_HINT_NAME_LENGTH = 128; + static final int MAX_HINT_VALUE_LENGTH = 255; + + private static final String CLIENT_NOTE_PREFIX = + AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + HINT_PREFIX; + private static final Pattern HINT_NAME_PATTERN = Pattern.compile("[A-Za-z0-9._-]+"); + private static final Set CREDENTIAL_FIELDS = + Set.of(RegistrationPage.FIELD_PASSWORD, RegistrationPage.FIELD_PASSWORD_CONFIRM); + + private LoginHintPrefill() {} + + static Map extractHints(Map clientNotes) { + Map hints = new LinkedHashMap<>(); + + for (Map.Entry clientNote : clientNotes.entrySet()) { + if (!clientNote.getKey().startsWith(CLIENT_NOTE_PREFIX)) { + continue; + } + + String attributeName = clientNote.getKey().substring(CLIENT_NOTE_PREFIX.length()); + String value = clientNote.getValue(); + if (hints.size() == MAX_HINT_COUNT + || attributeName.isEmpty() + || attributeName.length() > MAX_HINT_NAME_LENGTH + || !HINT_NAME_PATTERN.matcher(attributeName).matches() + || value == null + || value.isBlank() + || value.length() > MAX_HINT_VALUE_LENGTH) { + throw new IllegalArgumentException("Invalid login hint parameters"); + } + + hints.put(attributeName, value); + } + + return Map.copyOf(hints); + } + + static MultivaluedMap filterWritableHints( + Map hints, Attributes attributes, Set excludedAttributes) { + MultivaluedMap filteredHints = new MultivaluedHashMap<>(); + Map writableAttributes = attributes.getWritable(); + Map unmanagedAttributes = attributes.getUnmanagedAttributes(); + + hints.forEach( + (attributeName, value) -> { + if (!CREDENTIAL_FIELDS.contains(attributeName) + && !excludedAttributes.contains(attributeName) + && writableAttributes.containsKey(attributeName) + && !unmanagedAttributes.containsKey(attributeName) + && attributes.getMetadata(attributeName) != null) { + filteredHints.putSingle(attributeName, value); + } + }); + + return filteredHints; + } +} diff --git a/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/LoginHintRegistrationPrefill.java b/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/LoginHintRegistrationPrefill.java new file mode 100644 index 00000000000..1b9a2403b87 --- /dev/null +++ b/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/LoginHintRegistrationPrefill.java @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech +// +// SPDX-License-Identifier: AGPL-3.0-only +package sequent.keycloak.voter_enrollment; + +import com.google.auto.service.AutoService; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.keycloak.Config; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormActionFactory; +import org.keycloak.authentication.FormContext; +import org.keycloak.authentication.ValidationContext; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.UserProfileProvider; + +@AutoService(FormActionFactory.class) +public class LoginHintRegistrationPrefill implements FormAction, FormActionFactory { + + public static final String PROVIDER_ID = "login-hint-registration-prefill"; + + @Override + public void buildPage(FormContext context, LoginFormsProvider form) { + if (!"GET".equals(context.getHttpRequest().getHttpMethod())) { + return; + } + + Map hints; + try { + hints = LoginHintPrefill.extractHints(context.getAuthenticationSession().getClientNotes()); + } catch (IllegalArgumentException invalidHints) { + return; + } + + if (hints.isEmpty()) { + return; + } + + MultivaluedMap candidateFormData = new MultivaluedHashMap<>(); + hints.forEach(candidateFormData::putSingle); + UserProfile profile = + context + .getSession() + .getProvider(UserProfileProvider.class) + .create(UserProfileContext.REGISTRATION, candidateFormData); + MultivaluedMap writableHints = + LoginHintPrefill.filterWritableHints(hints, profile.getAttributes(), Set.of()); + + if (!writableHints.isEmpty()) { + form.setFormData(writableHints); + } + } + + @Override + public void validate(ValidationContext context) { + context.success(); + } + + @Override + public void success(FormContext context) {} + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {} + + @Override + public FormAction create(KeycloakSession session) { + return this; + } + + @Override + public String getDisplayType() { + return "Sequent: Login hint registration prefill"; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return new AuthenticationExecutionModel.Requirement[] { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.DISABLED + }; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "Prefills managed writable registration attributes from validated login hint parameters. Place this action before registration user creation."; + } + + @Override + public List getConfigProperties() { + return List.of(); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public void init(Config.Scope config) {} + + @Override + public void postInit(KeycloakSessionFactory factory) {} + + @Override + public void close() {} +} diff --git a/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/LoginHintPrefillTest.java b/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/LoginHintPrefillTest.java new file mode 100644 index 00000000000..4620b81d5a2 --- /dev/null +++ b/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/LoginHintPrefillTest.java @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech +// +// SPDX-License-Identifier: AGPL-3.0-only +package sequent.keycloak.voter_enrollment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +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.MultivaluedMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.keycloak.authentication.FormContext; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.http.HttpRequest; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.userprofile.AttributeMetadata; +import org.keycloak.userprofile.Attributes; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.UserProfileProvider; +import org.mockito.ArgumentMatchers; + +class LoginHintPrefillTest { + + @Test + void extractsOnlyBoundedNamespacedClientNotes() { + Map clientNotes = + Map.of( + clientNote("username"), + "voter@example.com", + clientNote("dateOfBirth"), + "2000-01-01", + "state", + "oidc-state"); + + assertEquals( + Map.of("username", "voter@example.com", "dateOfBirth", "2000-01-01"), + LoginHintPrefill.extractHints(clientNotes)); + } + + @Test + void rejectsAnInvalidHintSetWithoutReturningPartialData() { + Map tooManyHints = new HashMap<>(); + for (int index = 0; index <= LoginHintPrefill.MAX_HINT_COUNT; index++) { + tooManyHints.put(clientNote("field" + index), "value" + index); + } + + assertThrows(IllegalArgumentException.class, () -> LoginHintPrefill.extractHints(tooManyHints)); + assertThrows( + IllegalArgumentException.class, + () -> LoginHintPrefill.extractHints(Map.of(clientNote("first name"), "value"))); + assertThrows( + IllegalArgumentException.class, + () -> LoginHintPrefill.extractHints(Map.of(clientNote("username"), " "))); + } + + @Test + void keepsOnlyExplicitWritableManagedAttributes() { + Attributes attributes = mock(Attributes.class); + AttributeMetadata metadata = mock(AttributeMetadata.class); + when(attributes.getWritable()) + .thenReturn( + Map.of( + "username", List.of("voter@example.com"), + "dateOfBirth", List.of("2000-01-01"), + "unmanaged", List.of("value"))); + when(attributes.getUnmanagedAttributes()).thenReturn(Map.of("unmanaged", List.of("value"))); + when(attributes.getMetadata("username")).thenReturn(metadata); + when(attributes.getMetadata("dateOfBirth")).thenReturn(metadata); + + MultivaluedMap result = + LoginHintPrefill.filterWritableHints( + Map.of( + "username", "voter@example.com", + "dateOfBirth", "2000-01-01", + "verificationStatus", "VERIFIED", + "unmanaged", "value", + "password", "secret"), + attributes, + Set.of("verificationStatus")); + + assertEquals( + Map.of("username", List.of("voter@example.com"), "dateOfBirth", List.of("2000-01-01")), + result); + } + + @Test + void stockActionPrefillsOnlyInitialGetRender() { + FormContext context = mock(FormContext.class); + LoginFormsProvider form = mock(LoginFormsProvider.class); + HttpRequest request = mock(HttpRequest.class); + AuthenticationSessionModel authenticationSession = mock(AuthenticationSessionModel.class); + KeycloakSession session = mock(KeycloakSession.class); + UserProfileProvider profileProvider = mock(UserProfileProvider.class); + UserProfile profile = mock(UserProfile.class); + Attributes attributes = mock(Attributes.class); + AttributeMetadata metadata = mock(AttributeMetadata.class); + + when(context.getHttpRequest()).thenReturn(request); + when(request.getHttpMethod()).thenReturn("GET"); + when(context.getAuthenticationSession()).thenReturn(authenticationSession); + when(authenticationSession.getClientNotes()) + .thenReturn(Map.of(clientNote("username"), "voter@example.com")); + when(context.getSession()).thenReturn(session); + when(session.getProvider(UserProfileProvider.class)).thenReturn(profileProvider); + when(profileProvider.create( + eq(UserProfileContext.REGISTRATION), ArgumentMatchers.>any())) + .thenReturn(profile); + when(profile.getAttributes()).thenReturn(attributes); + when(attributes.getWritable()).thenReturn(Map.of("username", List.of("voter@example.com"))); + when(attributes.getUnmanagedAttributes()).thenReturn(Map.of()); + when(attributes.getMetadata("username")).thenReturn(metadata); + + new LoginHintRegistrationPrefill().buildPage(context, form); + + verify(form).setFormData(ArgumentMatchers.>any()); + + when(request.getHttpMethod()).thenReturn("POST"); + new LoginHintRegistrationPrefill().buildPage(context, form); + + verify(form).setFormData(ArgumentMatchers.>any()); + verify(profileProvider, never()) + .create(eq(UserProfileContext.ACCOUNT), ArgumentMatchers.>any()); + } + + private static String clientNote(String attributeName) { + return AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + + LoginHintPrefill.HINT_PREFIX + + attributeName; + } +} From 838a1a331c6d6d011cbf9cc5a85e186ce4f6196f Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 10:34:25 +0200 Subject: [PATCH 04/13] =?UTF-8?q?=E2=9C=A8=20Add=20deferred=20registration?= =?UTF-8?q?=20prefill=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DeferredRegistrationUserCreation.java | 67 +++++++++++- .../DeferredRegistrationUserCreationTest.java | 103 ++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreation.java b/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreation.java index 8e8c81b265f..d0293619c09 100644 --- a/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreation.java +++ b/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreation.java @@ -62,6 +62,7 @@ public class DeferredRegistrationUserCreation implements FormAction, FormActionF public static final String UNIQUE_ATTRIBUTES = "unique-attributes"; public static final String PASSWORD_REQUIRED = "password-required"; public static final String FORM_MODE = "form-mode"; + public static final String PREFILL_PARAMETERS_POLICY = "prefill-parameters-policy"; public static final String PASSWORD_EXPIRATION_USER_ATTRIBUTE = "password-expiration-user-attribute"; public static final String PASSWORD_EXPIRATION_USER_ATTRIBUTE_DEFAULT = @@ -83,6 +84,11 @@ public String getValue() { } } + public enum PrefillPolicy { + IGNORE, + ACCEPT + } + public static final String VERIFIED_VALUE = "VERIFIED"; public static final String VERIFIED_DEFAULT_ID = "sequent.read-only.id-card-number-validated"; public static final String ID_NUMBER = "sequent.read-only.id-card-number"; @@ -115,6 +121,15 @@ public List getConfigProperties() { FormMode.REGISTRATION.name()); formMode.setOptions(asList(FormMode.REGISTRATION.name(), FormMode.LOGIN.name())); + ProviderConfigProperty prefillPolicy = + new ProviderConfigProperty( + PREFILL_PARAMETERS_POLICY, + "Prefill Parameters Policy", + "Choose whether validated login hint parameters may prefill writable profile fields.", + ProviderConfigProperty.LIST_TYPE, + PrefillPolicy.IGNORE.name()); + prefillPolicy.setOptions(asList(PrefillPolicy.IGNORE.name(), PrefillPolicy.ACCEPT.name())); + // Define configuration properties return List.of( new ProviderConfigProperty( @@ -159,6 +174,7 @@ public List getConfigProperties() { "Comma-separated list of profile attributes to hide from the form and ignore if Keycloak marks them as required.", ProviderConfigProperty.STRING_TYPE, HIDDEN_PROFILE_ATTRIBUTES_DEFAULT), + prefillPolicy, formMode); } @@ -510,13 +526,62 @@ public void buildPage(FormContext context, LoginFormsProvider form) { final boolean passwordRequired = Boolean.parseBoolean(Optional.ofNullable(configMap.get(PASSWORD_REQUIRED)).orElse("true")); + Set hiddenProfileAttributes = getHiddenProfileAttributes(configMap); + prefillFromLoginHints(context, form, configMap, hiddenProfileAttributes); + form.setAttribute("passwordRequired", passwordRequired); form.setAttribute("formMode", formMode); - form.setAttribute("hiddenProfileAttributes", getHiddenProfileAttributes(configMap)); + form.setAttribute("hiddenProfileAttributes", hiddenProfileAttributes); log.infov("buildPage(): formMode = {0}", formMode); checkNotOtherUserAuthenticating(context); } + private void prefillFromLoginHints( + FormContext context, + LoginFormsProvider form, + Map configMap, + Set hiddenProfileAttributes) { + String policy = + Optional.ofNullable(configMap.get(PREFILL_PARAMETERS_POLICY)) + .orElse(PrefillPolicy.IGNORE.name()); + if (!PrefillPolicy.ACCEPT.name().equals(policy) + || !"GET".equals(context.getHttpRequest().getHttpMethod())) { + return; + } + + Map hints; + try { + hints = LoginHintPrefill.extractHints(context.getAuthenticationSession().getClientNotes()); + } catch (IllegalArgumentException invalidHints) { + return; + } + + if (hints.isEmpty()) { + return; + } + + MultivaluedMap candidateFormData = new MultivaluedHashMap<>(); + hints.forEach(candidateFormData::putSingle); + UserProfile profile = + context + .getSession() + .getProvider(UserProfileProvider.class) + .create(UserProfileContext.REGISTRATION, candidateFormData); + Set excludedAttributes = + Stream.concat( + hiddenProfileAttributes.stream(), + Stream.of( + Optional.ofNullable(configMap.get(Utils.USER_STATUS_ATTRIBUTE)) + .orElse(VERIFIED_DEFAULT_ID))) + .collect(Collectors.toUnmodifiableSet()); + MultivaluedMap writableHints = + LoginHintPrefill.filterWritableHints(hints, profile.getAttributes(), excludedAttributes); + + if (!writableHints.isEmpty()) { + form.setFormData(writableHints); + } + } + @Override public void success(FormContext context) { log.info("DeferredRegistrationUserCreation: success"); diff --git a/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreationTest.java b/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreationTest.java index e73e7eb4c34..96a17e291ee 100644 --- a/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreationTest.java +++ b/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreationTest.java @@ -6,17 +6,38 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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.MultivaluedHashMap; import jakarta.ws.rs.core.MultivaluedMap; import java.lang.reflect.Method; +import java.util.List; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; +import org.keycloak.authentication.FormContext; import org.keycloak.authentication.forms.RegistrationPage; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.http.HttpRequest; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; +import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.userprofile.AttributeMetadata; +import org.keycloak.userprofile.Attributes; +import org.keycloak.userprofile.UserProfile; +import org.keycloak.userprofile.UserProfileContext; +import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.ValidationException; import org.keycloak.validate.ValidationError; +import org.mockito.ArgumentMatchers; class DeferredRegistrationUserCreationTest { @@ -99,4 +120,86 @@ void isRequiredErrorForHiddenAttributeOnlyMatchesConfiguredHiddenAttributes() { DeferredRegistrationUserCreation.isRequiredErrorForHiddenAttribute( emailRequiredError, hiddenProfileAttributes)); } + + @Test + void prefillPolicyDefaultsToIgnore() { + ProviderConfigProperty policy = + new DeferredRegistrationUserCreation() + .getConfigProperties().stream() + .filter( + property -> + DeferredRegistrationUserCreation.PREFILL_PARAMETERS_POLICY.equals( + property.getName())) + .findFirst() + .orElseThrow(); + + assertEquals( + DeferredRegistrationUserCreation.PrefillPolicy.IGNORE.name(), policy.getDefaultValue()); + assertEquals( + List.of( + DeferredRegistrationUserCreation.PrefillPolicy.IGNORE.name(), + DeferredRegistrationUserCreation.PrefillPolicy.ACCEPT.name()), + policy.getOptions()); + } + + @Test + void prefillPolicyMustBeAcceptedAndOnlyAppliesVisibleAttributesOnGet() { + DeferredRegistrationUserCreation action = new DeferredRegistrationUserCreation(); + FormContext context = mock(FormContext.class); + LoginFormsProvider form = mock(LoginFormsProvider.class); + AuthenticatorConfigModel config = mock(AuthenticatorConfigModel.class); + HttpRequest request = mock(HttpRequest.class); + AuthenticationSessionModel authenticationSession = mock(AuthenticationSessionModel.class); + KeycloakSession session = mock(KeycloakSession.class); + UserProfileProvider profileProvider = mock(UserProfileProvider.class); + UserProfile profile = mock(UserProfile.class); + Attributes attributes = mock(Attributes.class); + AttributeMetadata metadata = mock(AttributeMetadata.class); + + Map configValues = new java.util.HashMap<>(); + configValues.put(DeferredRegistrationUserCreation.FORM_MODE, "REGISTRATION"); + when(context.getAuthenticatorConfig()).thenReturn(config); + when(config.getConfig()).thenReturn(configValues); + when(context.getHttpRequest()).thenReturn(request); + when(request.getHttpMethod()).thenReturn("GET"); + when(context.getAuthenticationSession()).thenReturn(authenticationSession); + when(authenticationSession.getClientNotes()) + .thenReturn( + Map.of( + clientNote("username"), "voter@example.com", + clientNote(UserModel.LOCALE), "es")); + when(context.getSession()).thenReturn(session); + when(session.getProvider(UserProfileProvider.class)).thenReturn(profileProvider); + when(profileProvider.create( + eq(UserProfileContext.REGISTRATION), ArgumentMatchers.>any())) + .thenReturn(profile); + when(profile.getAttributes()).thenReturn(attributes); + when(attributes.getWritable()) + .thenReturn( + Map.of("username", List.of("voter@example.com"), UserModel.LOCALE, List.of("es"))); + when(attributes.getUnmanagedAttributes()).thenReturn(Map.of()); + when(attributes.getMetadata("username")).thenReturn(metadata); + when(attributes.getMetadata(UserModel.LOCALE)).thenReturn(metadata); + + action.buildPage(context, form); + verify(form, never()).setFormData(any()); + + configValues.put( + DeferredRegistrationUserCreation.PREFILL_PARAMETERS_POLICY, + DeferredRegistrationUserCreation.PrefillPolicy.ACCEPT.name()); + action.buildPage(context, form); + MultivaluedMap expectedFormData = new MultivaluedHashMap<>(); + expectedFormData.putSingle("username", "voter@example.com"); + verify(form).setFormData(eq(expectedFormData)); + + when(request.getHttpMethod()).thenReturn("POST"); + action.buildPage(context, form); + verify(form).setFormData(any()); + } + + private static String clientNote(String attributeName) { + return AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + + LoginHintPrefill.HINT_PREFIX + + attributeName; + } } From 132d71f9c475622d91783d2d11009f61a472d42d Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 10:37:31 +0200 Subject: [PATCH 05/13] =?UTF-8?q?=E2=9C=85=20Cover=20redirect=20registrati?= =?UTF-8?q?on=20hint=20preservation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RedirectToRegisterAuthenticatorTest.java | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/keycloak-extensions/conditional-authenticators/src/test/java/sequent/keycloak/conditional_authenticators/RedirectToRegisterAuthenticatorTest.java 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; + } +} From f4e9d4bcaed2833fa20d7139401b01650a452259 Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 12:01:12 +0200 Subject: [PATCH 06/13] =?UTF-8?q?=E2=9C=A8=20Expose=20user=20attributes=20?= =?UTF-8?q?to=20notification=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/Cargo.lock | 1 + packages/sequent-core/Cargo.toml | 3 +- packages/sequent-core/src/services/reports.rs | 50 +++++++++++++ packages/windmill/src/tasks/send_template.rs | 70 ++++++++++++++++--- 4 files changed, 113 insertions(+), 11 deletions(-) diff --git a/packages/Cargo.lock b/packages/Cargo.lock index 26a892d3193..af083cf714f 100644 --- a/packages/Cargo.lock +++ b/packages/Cargo.lock @@ -8960,6 +8960,7 @@ dependencies = [ "tracing-log", "tracing-subscriber", "tracing-tree", + "urlencoding", "uuid", "warp", "wasm-bindgen", diff --git a/packages/sequent-core/Cargo.toml b/packages/sequent-core/Cargo.toml index 7c233c9ee28..a332ad8810d 100644 --- a/packages/sequent-core/Cargo.toml +++ b/packages/sequent-core/Cargo.toml @@ -81,6 +81,7 @@ phf = { version = "0.11", features = ["macros"], default-features = false } handlebars = { version = "6.1", optional = true } handlebars-chrono = { version = "0.2", features = [ "locale", "timezone" ], optional = true } num-format = { version = "0.4", optional = true } +urlencoding = { version = "2.1", optional = true } headless_chrome = { version = "1.0", optional = true } reqwest = { version="0.12", features = ["blocking", "json", "rustls-tls"], optional = true, default-features = false } reqwest-retry = { version = "0.7", optional = true } @@ -138,7 +139,7 @@ wasm = ["dep:wasm-bindgen", "areas", "dep:serde-wasm-bindgen"] signatures=["dep:tracing"] default = [] areas = [] -reports = ["dep:handlebars", "dep:handlebars-chrono", "dep:headless_chrome", "dep:reqwest", "dep:tracing", "dep:tokio", "dep:num-format", "dep:aws-config", "dep:aws-sdk-sesv2", "dep:aws-sdk-sns", "dep:aws-sdk-s3", "dep:aws-smithy-types", "dep:sha256", "dep:ammonia"] +reports = ["dep:handlebars", "dep:handlebars-chrono", "dep:headless_chrome", "dep:reqwest", "dep:tracing", "dep:tokio", "dep:num-format", "dep:urlencoding", "dep:aws-config", "dep:aws-sdk-sesv2", "dep:aws-sdk-sns", "dep:aws-sdk-s3", "dep:aws-smithy-types", "dep:sha256", "dep:ammonia"] reports_sync = [] s3 = ["dep:reqwest", "dep:aws-config", "dep:aws-sdk-sesv2", "dep:aws-sdk-sns", "dep:aws-sdk-s3", "dep:aws-smithy-types"] keycloak = ["jwt", "dep:openid", "dep:reqwest", "dep:reqwest-retry", "dep:reqwest-middleware", "dep:serde_urlencoded", "dep:rocket", "dep:keycloak", "dep:tracing", "dep:uuid", "dep:regex", "dep:jsonwebtoken", "dep:tokio-postgres", "dep:time"] diff --git a/packages/sequent-core/src/services/reports.rs b/packages/sequent-core/src/services/reports.rs index d6f95fe2ef0..d116a759bdd 100644 --- a/packages/sequent-core/src/services/reports.rs +++ b/packages/sequent-core/src/services/reports.rs @@ -62,6 +62,7 @@ fn get_registry<'reg>() -> Handlebars<'reg> { helper_wrapper_or(Box::new(inc2), String::from("-")), ); reg.register_helper("to_json", helper_wrapper(Box::new(to_json))); + reg.register_helper("url_encode", helper_wrapper(Box::new(url_encode))); reg.register_helper( "parse_i64", helper_wrapper_or(Box::new(parse_i64), String::from("-")), @@ -283,6 +284,31 @@ pub fn sanitize_html( Ok(()) } +/// Percent-encodes a string for use as one dynamic URL query value. +pub fn url_encode( + helper: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let value = helper + .param(0) + .ok_or(RenderErrorReason::ParamNotFoundForIndex("url_encode", 0))? + .value() + .as_str() + .ok_or_else(|| { + RenderErrorReason::ParamTypeMismatchForName( + "url_encode", + "0".to_string(), + "string".to_string(), + ) + })?; + + out.write(urlencoding::encode(value).as_ref())?; + Ok(()) +} + fn parse_u64_value(value: &JsonValue) -> Result { match value { JsonValue::Number(n) => n.as_u64().ok_or_else(|| { @@ -802,3 +828,27 @@ impl HelperDef for is_some { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::render_template_text; + use serde_json::{json, Map}; + + #[test] + fn url_encode_keeps_dynamic_data_in_one_query_value() { + let mut variables = Map::new(); + variables + .insert("value".to_string(), json!("a&admin=true 50% \"Málaga\"")); + + let rendered = render_template_text( + "https://vote.example/login?login_hint__reference={{url_encode value}}", + variables, + ) + .expect("template should render"); + + assert_eq!( + rendered, + "https://vote.example/login?login_hint__reference=a%26admin%3Dtrue%2050%25%20%22M%C3%A1laga%22" + ); + } +} diff --git a/packages/windmill/src/tasks/send_template.rs b/packages/windmill/src/tasks/send_template.rs index bc46eacab17..05b2ebe379c 100644 --- a/packages/windmill/src/tasks/send_template.rs +++ b/packages/windmill/src/tasks/send_template.rs @@ -50,16 +50,22 @@ fn get_variables( auth_action: AuthAction, ) -> Result> { let mut variables: Map = Default::default(); - variables.insert( - "user".to_string(), - json!({ - "first_name": user.first_name.clone(), - "last_name": user.last_name.clone(), - "username": user.username.clone(), - "first_name": user.first_name.clone(), - "email": user.email.clone(), - }), - ); + let mut user_variables = Map::new(); + user_variables.insert("first_name".to_string(), json!(user.first_name)); + user_variables.insert("last_name".to_string(), json!(user.last_name)); + user_variables.insert("username".to_string(), json!(user.username)); + user_variables.insert("email".to_string(), json!(user.email)); + + let attributes = user.attributes.clone().unwrap_or_default(); + for (attribute_name, values) in &attributes { + if let Some(first_value) = values.first() { + user_variables + .entry(attribute_name.clone()) + .or_insert_with(|| json!(first_value)); + } + } + user_variables.insert("attributes".to_string(), json!(attributes)); + variables.insert("user".to_string(), Value::Object(user_variables)); variables.insert("tenant_id".to_string(), json!(tenant_id.clone())); if let Some(ref election_event) = election_event { let default_language = election_event.get_default_language(); @@ -644,3 +650,47 @@ pub async fn send_template_email_or_sms( } } } + +#[cfg(test)] +mod tests { + use super::get_variables; + use sequent_core::services::generate_urls::AuthAction; + use sequent_core::types::keycloak::User; + use serde_json::json; + use std::collections::HashMap; + + #[test] + fn get_variables_exposes_dynamic_and_multivalued_user_attributes() { + let user = User { + username: Some("canonical-user".to_string()), + attributes: Some(HashMap::from([ + ( + "dateOfBirth".to_string(), + vec!["2000-01-01".to_string(), "ignored-first-value".to_string()], + ), + ( + "username".to_string(), + vec!["untrusted-collision".to_string()], + ), + ("empty".to_string(), Vec::new()), + ])), + ..User::default() + }; + + let variables = get_variables(&user, None, "tenant-id".to_string(), AuthAction::Login) + .expect("variables should be generated"); + + assert_eq!(variables["user"]["username"], json!("canonical-user")); + assert_eq!(variables["user"]["dateOfBirth"], json!("2000-01-01")); + assert!(variables["user"].get("empty").is_none()); + assert_eq!( + variables["user"]["attributes"]["dateOfBirth"], + json!(["2000-01-01", "ignored-first-value"]) + ); + assert_eq!( + variables["user"]["attributes"]["username"], + json!(["untrusted-collision"]) + ); + assert_eq!(variables["user"]["attributes"]["empty"], json!([])); + } +} From e0b7957cbb17c67c919331c87846ac826f6912b7 Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 12:17:05 +0200 Subject: [PATCH 07/13] =?UTF-8?q?=E2=9C=85=20Add=20login=20hint=20test=20m?= =?UTF-8?q?atrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LoginHintPrefillTest.java | 49 ++++++++++ .../test/e2e/login_hints.test.ts | 93 +++++++++++++++++++ packages/voting-portal/test/readme.md | 15 +++ 3 files changed, 157 insertions(+) create mode 100644 packages/voting-portal/test/e2e/login_hints.test.ts diff --git a/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/LoginHintPrefillTest.java b/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/LoginHintPrefillTest.java index 4620b81d5a2..07833eb9c8d 100644 --- a/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/LoginHintPrefillTest.java +++ b/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/LoginHintPrefillTest.java @@ -3,23 +3,32 @@ // SPDX-License-Identifier: AGPL-3.0-only package sequent.keycloak.voter_enrollment; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import jakarta.ws.rs.core.MultivaluedMap; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; +import org.keycloak.authentication.FormActionFactory; import org.keycloak.authentication.FormContext; import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.http.HttpRequest; +import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.KeycloakSession; import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; import org.keycloak.sessions.AuthenticationSessionModel; @@ -133,6 +142,46 @@ void stockActionPrefillsOnlyInitialGetRender() { .create(eq(UserProfileContext.ACCOUNT), ArgumentMatchers.>any()); } + @Test + void stockActionRejectsAnInvalidHintSetAtomically() { + FormContext context = mock(FormContext.class); + LoginFormsProvider form = mock(LoginFormsProvider.class); + HttpRequest request = mock(HttpRequest.class); + AuthenticationSessionModel authenticationSession = mock(AuthenticationSessionModel.class); + + when(context.getHttpRequest()).thenReturn(request); + when(request.getHttpMethod()).thenReturn("GET"); + when(context.getAuthenticationSession()).thenReturn(authenticationSession); + when(authenticationSession.getClientNotes()) + .thenReturn( + Map.of( + clientNote("username"), "voter@example.com", clientNote("invalid field"), "value")); + + new LoginHintRegistrationPrefill().buildPage(context, form); + + verifyNoInteractions(form); + } + + @Test + void stockActionFactoryIsRegisteredWithExpectedRequirements() throws IOException { + LoginHintRegistrationPrefill action = new LoginHintRegistrationPrefill(); + String serviceResource = "META-INF/services/" + FormActionFactory.class.getName(); + + try (InputStream providers = getClass().getClassLoader().getResourceAsStream(serviceResource)) { + assertNotNull(providers); + String registeredProviders = new String(providers.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(registeredProviders.contains(LoginHintRegistrationPrefill.class.getName())); + } + + assertEquals(LoginHintRegistrationPrefill.PROVIDER_ID, action.getId()); + assertArrayEquals( + new AuthenticationExecutionModel.Requirement[] { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.DISABLED + }, + action.getRequirementChoices()); + } + private static String clientNote(String attributeName) { return AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + LoginHintPrefill.HINT_PREFIX diff --git a/packages/voting-portal/test/e2e/login_hints.test.ts b/packages/voting-portal/test/e2e/login_hints.test.ts new file mode 100644 index 00000000000..125315fa03a --- /dev/null +++ b/packages/voting-portal/test/e2e/login_hints.test.ts @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech +// +// SPDX-License-Identifier: AGPL-3.0-only + +import {NightwatchAPI} from "nightwatch" + +const username = "prefilled-voter@example.com" +const dateOfBirth = "2000-01-01" +const dateOfBirthSelector = 'input[name="dateOfBirth"]' + +type HintScenario = { + environmentVariable: string + route: "login" | "enroll" + fieldSelector: string + expectedValue: string + hints: Record +} + +const scenarios: Record = { + "stock username login": { + environmentVariable: "PREFILL_STOCK_LOGIN_URL", + route: "login", + fieldSelector: 'input[name="username"]', + expectedValue: username, + hints: {username}, + }, + "direct stock registration": { + environmentVariable: "PREFILL_STOCK_REGISTRATION_URL", + route: "enroll", + fieldSelector: dateOfBirthSelector, + expectedValue: dateOfBirth, + hints: {username, dateOfBirth}, + }, + "login redirected to stock registration": { + environmentVariable: "PREFILL_REDIRECT_REGISTRATION_URL", + route: "login", + fieldSelector: dateOfBirthSelector, + expectedValue: dateOfBirth, + hints: {username, dateOfBirth}, + }, + "deferred registration ignores hints by default": { + environmentVariable: "PREFILL_DEFERRED_IGNORE_URL", + route: "enroll", + fieldSelector: dateOfBirthSelector, + expectedValue: "", + hints: {dateOfBirth}, + }, + "deferred registration accepts hints when enabled": { + environmentVariable: "PREFILL_DEFERRED_ACCEPT_URL", + route: "enroll", + fieldSelector: dateOfBirthSelector, + expectedValue: dateOfBirth, + hints: {dateOfBirth}, + }, +} + +function buildVotingPortalUrl(scenario: HintScenario): string | undefined { + const configuredUrl = process.env[scenario.environmentVariable] + if (!configuredUrl) { + return undefined + } + + const url = new URL(configuredUrl) + if (!url.pathname.endsWith(`/${scenario.route}`)) { + throw new Error( + `${scenario.environmentVariable} must be a Voting Portal /${scenario.route} URL` + ) + } + + for (const [fieldName, value] of Object.entries(scenario.hints)) { + url.searchParams.set(`login_hint__${fieldName}`, value) + } + return url.toString() +} + +describe("login hint browser matrix", function () { + for (const [name, scenario] of Object.entries(scenarios)) { + const votingPortalUrl = buildVotingPortalUrl(scenario) + const test = votingPortalUrl ? it : it.skip + + test(name, function (browser: NightwatchAPI) { + browser + .navigateTo(votingPortalUrl!) + .waitForElementVisible("body") + .waitForElementVisible(scenario.fieldSelector) + .assert.valueEquals(scenario.fieldSelector, scenario.expectedValue) + }) + } + + after(function (browser) { + browser.end() + }) +}) diff --git a/packages/voting-portal/test/readme.md b/packages/voting-portal/test/readme.md index ba3d62c47f0..82513a1ef82 100644 --- a/packages/voting-portal/test/readme.md +++ b/packages/voting-portal/test/readme.md @@ -22,3 +22,18 @@ SPDX-License-Identifier: AGPL-3.0-only ### Run test - cwd /voting-portal in local workspace(not codespace) - npx nightwatch path/t0.test.ts + +### Login hint browser matrix + +Configure each variable with a Voting Portal tenant/event URL. The stock registration +realm must include the `login-hint-registration-prefill` action. The deferred realms +must configure `prefill-parameters-policy` as `IGNORE` and `ACCEPT`, respectively. + +- `PREFILL_STOCK_LOGIN_URL`: Voting Portal `/login` URL using the stock username form +- `PREFILL_STOCK_REGISTRATION_URL`: Voting Portal `/enroll` URL using stock registration +- `PREFILL_REDIRECT_REGISTRATION_URL`: Voting Portal `/login` URL whose flow redirects to registration +- `PREFILL_DEFERRED_IGNORE_URL`: Voting Portal `/enroll` URL using deferred registration with `IGNORE` +- `PREFILL_DEFERRED_ACCEPT_URL`: Voting Portal `/enroll` URL using deferred registration with `ACCEPT` + +Run `npx nightwatch test/e2e/login_hints.test.ts`. Scenarios without a configured URL +are reported as skipped. From 4ab6ec58b7f00618497b108731e15369ea066ff8 Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 12:20:18 +0200 Subject: [PATCH 08/13] =?UTF-8?q?=F0=9F=93=9D=20Document=20login=20hint=20?= =?UTF-8?q?prefill=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ion_management_election-event_templates.md | 54 ++++++++++++++++- .../08-windmill/developers_windmill.md | 58 +++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) 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..2865d8b7052 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,57 @@ 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. Empty custom value lists are present only under +`user.attributes`. + +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. + +:::warning +Prefilled values are editable convenience data, not verified identity claims. +They never bypass authentication, registration validation, or required actions. +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..dfca26e03d9 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,64 @@ 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. + ## Tally ### Discarded/Auditable Ballots From 69db7c892ff12bb1c63b64123672a9ee373b8384 Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 12:22:02 +0200 Subject: [PATCH 09/13] =?UTF-8?q?=F0=9F=93=9D=20Add=20login=20hint=20migra?= =?UTF-8?q?tion=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../08-release-9.5/release-9.5.0.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/docusaurus/docs/08-releases/08-release-9.5/release-9.5.0.md b/docs/docusaurus/docs/08-releases/08-release-9.5/release-9.5.0.md index 47d8b33e3e2..15341c2419e 100644 --- a/docs/docusaurus/docs/08-releases/08-release-9.5/release-9.5.0.md +++ b/docs/docusaurus/docs/08-releases/08-release-9.5/release-9.5.0.md @@ -24,6 +24,40 @@ See [sequentech/meta#11928](https://github.com/sequentech/meta/issues/11928) for ### ✨ Import versioning constraints Events can no longer be imported into a different major version. If you rely on cross-version imports, review [sequentech/meta#11114](https://github.com/sequentech/meta/issues/11114) before upgrading. +### ✨ Prefill login and registration fields from notification links + +Voting Portal `/login` and `/enroll` routes now accept up to five bounded +`login_hint__` query parameters. A `username` value is also forwarded as +the standard OIDC `login_hint`. Existing links continue to work unchanged. + +For deployments enabling prefill: + +1. **Notification templates**: append only approved `login_hint__` + parameters. Wrap every dynamic value with the Handlebars `url_encode` helper. + Custom Keycloak attributes are available as `user.` for the first + value and `user.attributes.` for the complete value array. +2. **Stock Keycloak registration**: add the + **Sequent: Login hint registration prefill** action as **Required** before the + registration user-creation execution, then bind the updated registration + flow to the realm. +3. **Deferred registration**: leave **Prefill Parameters Policy** at its default + `IGNORE` value unless the realm explicitly permits prefill. Set it to `ACCEPT` + only after reviewing which managed, writable profile fields may appear in + notification URLs. +4. **Realm rollout**: update and upload the default tenant and election-event + realm templates for newly created realms. Update existing realms separately; + replacing a default template does not modify them. +5. **Verification**: test direct `/enroll`, `/login` redirected to registration, + and deferred registration. Confirm submitted form values override hints and + verification or credential fields are never prefilled. + +Hints are editable, untrusted convenience data. Do not include passwords, +tokens, secrets, or inappropriate sensitive identifiers, and never use a hint to +bypass authentication or mark identity, contact details, or eligibility as +verified. Percent encoding protects query structure, not confidentiality or +authenticity. See [sequentech/meta#12617](https://github.com/sequentech/meta/issues/12617) +for details. + ## 📝 Highlights ### ✨ Keycloak and authentication enhancements From 5f095d589f2c991ab1f38552d5660e7aa657f0ce Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 13:47:16 +0200 Subject: [PATCH 10/13] =?UTF-8?q?=F0=9F=94=92=20Harden=20login=20hint=20tr?= =?UTF-8?q?ust=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ion_management_election-event_templates.md | 12 +++- .../08-windmill/developers_windmill.md | 4 +- .../08-release-9.5/release-9.5.0.md | 3 +- .../voter_enrollment/LoginHintPrefill.java | 12 +++- .../LoginHintPrefillTest.java | 6 ++ packages/sequent-core/src/services/reports.rs | 16 +++-- packages/voting-portal/src/App.tsx | 17 +++-- .../src/providers/AuthContextProvider.tsx | 4 +- .../src/utils/loginHints.test.ts | 40 +++++++++++ .../voting-portal/src/utils/loginHints.ts | 66 +++++++++++++++++-- .../src/services/providers/email_sender.rs | 19 ++---- .../src/services/providers/sms_sender.rs | 12 +--- packages/windmill/src/tasks/send_template.rs | 47 +++++++------ 13 files changed, 189 insertions(+), 69 deletions(-) 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 2865d8b7052..aaa148439cf 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 @@ -52,8 +52,16 @@ Email and SMS Handlebars templates can use these standard voter variables: 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. Empty custom value lists are present only under -`user.attributes`. +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: 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 dfca26e03d9..bcb6b1dd8b4 100644 --- a/docs/docusaurus/docs/07-developers/08-windmill/developers_windmill.md +++ b/docs/docusaurus/docs/07-developers/08-windmill/developers_windmill.md @@ -128,7 +128,9 @@ 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. +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 diff --git a/docs/docusaurus/docs/08-releases/08-release-9.5/release-9.5.0.md b/docs/docusaurus/docs/08-releases/08-release-9.5/release-9.5.0.md index 15341c2419e..a55376212e9 100644 --- a/docs/docusaurus/docs/08-releases/08-release-9.5/release-9.5.0.md +++ b/docs/docusaurus/docs/08-releases/08-release-9.5/release-9.5.0.md @@ -35,7 +35,8 @@ For deployments enabling prefill: 1. **Notification templates**: append only approved `login_hint__` parameters. Wrap every dynamic value with the Handlebars `url_encode` helper. Custom Keycloak attributes are available as `user.` for the first - value and `user.attributes.` for the complete value array. + value and `user.attributes.` for the complete value array. Use + `{{lookup user "attribute.with-dots"}}` for names containing dots or dashes. 2. **Stock Keycloak registration**: add the **Sequent: Login hint registration prefill** action as **Required** before the registration user-creation execution, then bind the updated registration diff --git a/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/LoginHintPrefill.java b/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/LoginHintPrefill.java index e1f7fe60b03..a5e4a21a68c 100644 --- a/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/LoginHintPrefill.java +++ b/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/LoginHintPrefill.java @@ -11,6 +11,7 @@ import java.util.regex.Pattern; import org.keycloak.authentication.forms.RegistrationPage; import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; +import org.keycloak.userprofile.AttributeMetadata; import org.keycloak.userprofile.Attributes; final class LoginHintPrefill { @@ -23,6 +24,8 @@ final class LoginHintPrefill { private static final String CLIENT_NOTE_PREFIX = AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + HINT_PREFIX; private static final Pattern HINT_NAME_PATTERN = Pattern.compile("[A-Za-z0-9._-]+"); + private static final String INPUT_TYPE_ANNOTATION = "inputType"; + private static final String HIDDEN_INPUT_TYPE = "hidden"; private static final Set CREDENTIAL_FIELDS = Set.of(RegistrationPage.FIELD_PASSWORD, RegistrationPage.FIELD_PASSWORD_CONFIRM); @@ -62,15 +65,22 @@ static MultivaluedMap filterWritableHints( hints.forEach( (attributeName, value) -> { + AttributeMetadata metadata = attributes.getMetadata(attributeName); if (!CREDENTIAL_FIELDS.contains(attributeName) && !excludedAttributes.contains(attributeName) && writableAttributes.containsKey(attributeName) && !unmanagedAttributes.containsKey(attributeName) - && attributes.getMetadata(attributeName) != null) { + && metadata != null + && !isHidden(metadata)) { filteredHints.putSingle(attributeName, value); } }); return filteredHints; } + + private static boolean isHidden(AttributeMetadata metadata) { + Map annotations = metadata.getAnnotations(); + return annotations != null && HIDDEN_INPUT_TYPE.equals(annotations.get(INPUT_TYPE_ANNOTATION)); + } } diff --git a/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/LoginHintPrefillTest.java b/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/LoginHintPrefillTest.java index 07833eb9c8d..5004fba4a83 100644 --- a/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/LoginHintPrefillTest.java +++ b/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/LoginHintPrefillTest.java @@ -77,15 +77,20 @@ void rejectsAnInvalidHintSetWithoutReturningPartialData() { void keepsOnlyExplicitWritableManagedAttributes() { Attributes attributes = mock(Attributes.class); AttributeMetadata metadata = mock(AttributeMetadata.class); + AttributeMetadata hiddenMetadata = mock(AttributeMetadata.class); + when(metadata.getAnnotations()).thenReturn(Map.of()); + when(hiddenMetadata.getAnnotations()).thenReturn(Map.of("inputType", "hidden")); when(attributes.getWritable()) .thenReturn( Map.of( "username", List.of("voter@example.com"), "dateOfBirth", List.of("2000-01-01"), + "hiddenReference", List.of("internal-value"), "unmanaged", List.of("value"))); when(attributes.getUnmanagedAttributes()).thenReturn(Map.of("unmanaged", List.of("value"))); when(attributes.getMetadata("username")).thenReturn(metadata); when(attributes.getMetadata("dateOfBirth")).thenReturn(metadata); + when(attributes.getMetadata("hiddenReference")).thenReturn(hiddenMetadata); MultivaluedMap result = LoginHintPrefill.filterWritableHints( @@ -93,6 +98,7 @@ void keepsOnlyExplicitWritableManagedAttributes() { "username", "voter@example.com", "dateOfBirth", "2000-01-01", "verificationStatus", "VERIFIED", + "hiddenReference", "internal-value", "unmanaged", "value", "password", "secret"), attributes, diff --git a/packages/sequent-core/src/services/reports.rs b/packages/sequent-core/src/services/reports.rs index d116a759bdd..688979fc6f9 100644 --- a/packages/sequent-core/src/services/reports.rs +++ b/packages/sequent-core/src/services/reports.rs @@ -186,10 +186,8 @@ pub fn helper_wrapper<'a>( Ok(val) => Ok(val), Err(err) => { warn!( - "Error calling helper name={name:?} with params={params:?}, hash={hash:?}: {err:?}", - name=helper.name(), - params=helper.params(), - hash=helper.hash() + "Error calling helper name={name:?}: {err:?}", + name = helper.name(), ); Err(err) } @@ -851,4 +849,14 @@ mod tests { "https://vote.example/login?login_hint__reference=a%26admin%3Dtrue%2050%25%20%22M%C3%A1laga%22" ); } + + #[test] + fn url_encode_rejects_non_string_values() { + let mut variables = Map::new(); + variables.insert("value".to_string(), json!(["private", "values"])); + + assert!( + render_template_text("{{url_encode value}}", variables).is_err() + ); + } } diff --git a/packages/voting-portal/src/App.tsx b/packages/voting-portal/src/App.tsx index f67302ef060..06ba4d23165 100644 --- a/packages/voting-portal/src/App.tsx +++ b/packages/voting-portal/src/App.tsx @@ -33,7 +33,12 @@ import WatermarkBackground from "./components/WaterMark/Watermark" import SequentLogo from "@sequentech/ui-essentials/public/Sequent_logo.svg" import BlankLogoImg from "@sequentech/ui-essentials/public/blank_logo.svg" import {useElectionClassName} from "./hooks/useElectionClassName" -import {InvalidLoginHintsError, parseLoginHints} from "./utils/loginHints" +import { + InvalidLoginHintsError, + parseLoginHints, + removeLoginHintsFromSearch, + routeAcceptsLoginHints, +} from "./utils/loginHints" interface ElectionEventConfigDocument { id: string tenant_id: string @@ -134,9 +139,7 @@ const App = () => { const {tenantId, eventId} = useParams() const {isAuthenticated, setTenantEvent} = useContext(AuthContext) const [loginHintRequest] = useState(() => { - const acceptsLoginHints = ["/login", "/enroll"].some((suffix) => - location.pathname.endsWith(suffix) - ) + const acceptsLoginHints = routeAcceptsLoginHints(location.pathname) try { const parsed = acceptsLoginHints @@ -145,6 +148,12 @@ const App = () => { return {...parsed, pathname: location.pathname, hash: location.hash} } catch (error) { if (error instanceof InvalidLoginHintsError) { + const remainingSearch = removeLoginHintsFromSearch(location.search) + window.history.replaceState( + window.history.state, + "", + `${location.pathname}${remainingSearch}${location.hash}` + ) throw new VotingPortalError(VotingPortalErrorType.INVALID_LOGIN_HINT_PARAMETERS) } throw error diff --git a/packages/voting-portal/src/providers/AuthContextProvider.tsx b/packages/voting-portal/src/providers/AuthContextProvider.tsx index fc5ed38e5c4..08269a80314 100644 --- a/packages/voting-portal/src/providers/AuthContextProvider.tsx +++ b/packages/voting-portal/src/providers/AuthContextProvider.tsx @@ -306,8 +306,8 @@ const AuthContextProvider = (props: AuthContextProviderProps) => { const registerOptions = { ...keycloakInitOptions, // after successful enrollment, we should redirect to login - redirectUri: baseUrl.endsWith("/enroll") - ? baseUrl.replace(/\/enroll$/, "/login") + queryString + redirectUri: /\/enroll\/?$/.test(baseUrl) + ? baseUrl.replace(/\/enroll\/?$/, "/login") + queryString : undefined, loginHint: loginHints.username, } diff --git a/packages/voting-portal/src/utils/loginHints.test.ts b/packages/voting-portal/src/utils/loginHints.test.ts index 1e2e83924bf..71a10e4c362 100644 --- a/packages/voting-portal/src/utils/loginHints.test.ts +++ b/packages/voting-portal/src/utils/loginHints.test.ts @@ -9,6 +9,8 @@ import { MAX_LOGIN_HINT_VALUE_LENGTH, appendLoginHints, parseLoginHints, + removeLoginHintsFromSearch, + routeAcceptsLoginHints, } from "./loginHints" describe("parseLoginHints", () => { @@ -66,9 +68,34 @@ describe("parseLoginHints", () => { ).join("&")}`, ], ["a duplicate field", "?login_hint__username=first&login_hint__username=second"], + ["an invalid percent escape", "?login_hint__username=user%ZZexample"], + ["invalid UTF-8", "?login_hint__username=%E0%A4%A"], ])("rejects %s", (_description, search) => { expect(() => parseLoginHints(search)).toThrow(InvalidLoginHintsError) }) + + it("does not apply hint encoding validation to unrelated parameters", () => { + expect(parseLoginHints("?return_to=%ZZ")).toEqual({ + hints: {}, + remainingSearch: "?return_to=%25ZZ", + }) + }) + + it("treats prototype property names as ordinary bounded hints", () => { + const parsed = parseLoginHints("?login_hint____proto__=value&lang=en") + + expect(Object.hasOwn(parsed.hints, "__proto__")).toBe(true) + expect(parsed.hints.__proto__).toBe("value") + expect(parsed.remainingSearch).toBe("?lang=en") + }) + + it("removes invalid raw hint components while preserving unrelated query text", () => { + expect( + removeLoginHintsFromSearch( + "?lang=en&login_hint__username=%E0%A4%A&login_hint__username=duplicate&kiosk" + ) + ).toBe("?lang=en&kiosk") + }) }) describe("appendLoginHints", () => { @@ -92,3 +119,16 @@ describe("appendLoginHints", () => { expect(resultUrl.searchParams.get("b")).toBeNull() }) }) + +describe("routeAcceptsLoginHints", () => { + it.each(["/tenant/t/event/e/login", "/tenant/t/event/e/login/", "/enroll", "/enroll/"])( + "accepts %s", + (pathname) => { + expect(routeAcceptsLoginHints(pathname)).toBe(true) + } + ) + + it("rejects non-authentication routes", () => { + expect(routeAcceptsLoginHints("/tenant/t/event/e/vote")).toBe(false) + }) +}) diff --git a/packages/voting-portal/src/utils/loginHints.ts b/packages/voting-portal/src/utils/loginHints.ts index c0f8b648647..dd3e4b6a839 100644 --- a/packages/voting-portal/src/utils/loginHints.ts +++ b/packages/voting-portal/src/utils/loginHints.ts @@ -23,6 +23,9 @@ export class InvalidLoginHintsError extends Error { } } +export const routeAcceptsLoginHints = (pathname: string): boolean => + /\/(login|enroll)\/?$/.test(pathname) + const validateLoginHint = (name: string, value: string): void => { if ( !name || @@ -35,9 +38,36 @@ const validateLoginHint = (name: string, value: string): void => { } } +const validateLoginHintEncoding = (search: string): void => { + for (const component of search.replace(/^\?/, "").split("&")) { + const separatorIndex = component.indexOf("=") + const rawName = separatorIndex === -1 ? component : component.slice(0, separatorIndex) + const rawValue = separatorIndex === -1 ? "" : component.slice(separatorIndex + 1) + + let parameterName: string + try { + parameterName = decodeURIComponent(rawName.replaceAll("+", " ")) + } catch { + if (rawName.startsWith(LOGIN_HINT_PREFIX)) { + throw new InvalidLoginHintsError() + } + continue + } + + if (parameterName.startsWith(LOGIN_HINT_PREFIX)) { + try { + decodeURIComponent(rawValue.replaceAll("+", " ")) + } catch { + throw new InvalidLoginHintsError() + } + } + } +} + export const parseLoginHints = (search: string): ParsedLoginHints => { + validateLoginHintEncoding(search) const searchParams = new URLSearchParams(search) - const hints: LoginHints = {} + const hints = new Map() for (const [parameterName, value] of searchParams.entries()) { if (!parameterName.startsWith(LOGIN_HINT_PREFIX)) { @@ -47,27 +77,51 @@ export const parseLoginHints = (search: string): ParsedLoginHints => { const hintName = parameterName.slice(LOGIN_HINT_PREFIX.length) validateLoginHint(hintName, value) - if (Object.hasOwn(hints, hintName)) { + if (hints.has(hintName)) { throw new InvalidLoginHintsError() } - hints[hintName] = value - if (Object.keys(hints).length > MAX_LOGIN_HINT_COUNT) { + hints.set(hintName, value) + if (hints.size > MAX_LOGIN_HINT_COUNT) { throw new InvalidLoginHintsError() } } - for (const hintName of Object.keys(hints)) { + for (const hintName of hints.keys()) { searchParams.delete(`${LOGIN_HINT_PREFIX}${hintName}`) } const remainingSearch = searchParams.toString() return { - hints, + hints: Object.fromEntries(hints), remainingSearch: remainingSearch ? `?${remainingSearch}` : "", } } +export const removeLoginHintsFromSearch = (search: string): string => { + const remainingComponents = search + .replace(/^\?/, "") + .split("&") + .filter((component) => { + const separatorIndex = component.indexOf("=") + const rawName = separatorIndex === -1 ? component : component.slice(0, separatorIndex) + + if (rawName.startsWith(LOGIN_HINT_PREFIX)) { + return false + } + + try { + return !decodeURIComponent(rawName.replaceAll("+", " ")).startsWith( + LOGIN_HINT_PREFIX + ) + } catch { + return true + } + }) + + return remainingComponents.length > 0 ? `?${remainingComponents.join("&")}` : "" +} + export const appendLoginHints = (url: string, hints: LoginHints): string => { const result = new URL(url) const hintEntries = Object.entries(hints) diff --git a/packages/windmill/src/services/providers/email_sender.rs b/packages/windmill/src/services/providers/email_sender.rs index 72edfef4a67..0bcf8507a69 100644 --- a/packages/windmill/src/services/providers/email_sender.rs +++ b/packages/windmill/src/services/providers/email_sender.rs @@ -95,7 +95,7 @@ impl EmailSender { }) } - #[instrument(skip(self, plaintext_body, html_body, attachments), err)] + #[instrument(skip_all, err)] pub async fn send( &self, receivers: Vec, @@ -165,11 +165,7 @@ impl EmailSender { match self.transport { EmailTransport::AwsSes(ref aws_client) => { - event!( - Level::INFO, - "EmailTransport::AwsSes: Sending email:\n\t - receivers={receivers:?}\n\t - subject={subject}\n\t - plaintext_body={plaintext_body:.255}\n\t - html_body={html_body:.255}", - html_body=html_body.clone().unwrap_or_default(), - ); + event!(Level::INFO, "EmailTransport::AwsSes: Sending email",); if !attachments.is_empty() { for attachment in &attachments { event!( @@ -204,10 +200,7 @@ impl EmailSender { .map_err(|err| anyhow!("error sending email: {err:?}"))?; } EmailTransport::Smtp(ref smtp_transport) => { - event!( - Level::INFO, - "EmailTransport::Smtp: Sending email:\n\t - receivers={receivers:?}\n\t - subject={subject}", - ); + event!(Level::INFO, "EmailTransport::Smtp: Sending email",); if !attachments.is_empty() { for attachment in &attachments { event!( @@ -225,11 +218,7 @@ impl EmailSender { event!(Level::INFO, "Email sent successfully via SMTP"); } EmailTransport::Console => { - event!( - Level::INFO, - "EmailTransport::Console: Sending email:\n\t - receivers={receivers:?}\n\t - subject={subject}\n\t - plaintext_body={plaintext_body:.255}\n\t - html_body={html_body:.255}", - html_body=html_body.clone().unwrap_or_default(), - ); + event!(Level::INFO, "EmailTransport::Console: Simulating email",); if !attachments.is_empty() { for attachment in &attachments { event!( diff --git a/packages/windmill/src/services/providers/sms_sender.rs b/packages/windmill/src/services/providers/sms_sender.rs index 1cb868aae3f..3f097c89f02 100644 --- a/packages/windmill/src/services/providers/sms_sender.rs +++ b/packages/windmill/src/services/providers/sms_sender.rs @@ -66,14 +66,11 @@ impl SmsSender { }) } - #[instrument(skip(self, message), err)] + #[instrument(skip_all, err)] pub async fn send(&self, receiver: String, message: String) -> Result<()> { match self.transport { SmsTransport::AwsSns((ref aws_client, ref messsage_attributes)) => { - event!( - Level::INFO, - "SmsTransport::AwsSes: Sending SMS:\n\t - receiver={receiver}\n\t - message={message:.255}", - ); + event!(Level::INFO, "SmsTransport::AwsSns: Sending SMS",); aws_client .publish() .set_message_attributes(messsage_attributes.clone()) @@ -84,10 +81,7 @@ impl SmsSender { .map_err(|err| anyhow!("SmsTransport::AwsSes send error: {err:?}"))?; } SmsTransport::Console => { - event!( - Level::INFO, - "SmsTransport::Console: Sending SMS:\n\t - receiver={receiver}\n\t - message={message}", - ); + event!(Level::INFO, "SmsTransport::Console: Simulating SMS",); } } diff --git a/packages/windmill/src/tasks/send_template.rs b/packages/windmill/src/tasks/send_template.rs index 05b2ebe379c..adb367d0ff6 100644 --- a/packages/windmill/src/tasks/send_template.rs +++ b/packages/windmill/src/tasks/send_template.rs @@ -42,7 +42,7 @@ use std::default::Default; use strand::info; use tracing::{event, info, instrument, Level}; -#[instrument(err)] +#[instrument(skip_all, err)] fn get_variables( user: &User, election_event: Option, @@ -91,7 +91,11 @@ fn get_variables( Ok(variables) } -#[instrument(skip(sender), err)] +fn delivery_audit_message(channel: &str) -> String { + json!({"channel": channel}).to_string() +} + +#[instrument(skip_all, err)] async fn send_template_sms( receiver: &Option, template: &Option, @@ -103,20 +107,14 @@ async fn send_template_sms( .map_err(|err| anyhow!("{}", err))?; sender.send(receiver.into(), message.clone()).await?; - return Ok(Some( - json!({ - "receiver": receiver, - "message": message - }) - .to_string(), - )); + return Ok(Some(delivery_audit_message("sms"))); } else { event!(Level::INFO, "Receiver empty, ignoring.."); } Ok(None) } -#[instrument(skip(sender), err)] +#[instrument(skip_all, err)] pub async fn send_template_email( receiver: &Option, template: &Option, @@ -138,8 +136,6 @@ pub async fn send_template_email( ), None => None, }; - info!("html_body: {html_body:?}"); - sender .send( vec![receiver.to_string()], @@ -151,15 +147,7 @@ pub async fn send_template_email( .await .map_err(|err| anyhow!("error sending email: {err:?}"))?; - return Ok(Some( - json!({ - "receiver": receiver, - "subject": subject, - "html_body": html_body, - "plaintext_body": plaintext_body - }) - .to_string(), - )); + return Ok(Some(delivery_audit_message("email"))); } else { // Log the event if the receiver or template is missing event!( @@ -324,7 +312,7 @@ async fn on_success_send_message( Ok(()) } -#[instrument(err)] +#[instrument(skip_all, err)] #[wrap_map_err::wrap_map_err(TaskError)] #[celery::task] pub async fn send_template( @@ -535,7 +523,7 @@ pub async fn send_template( /// /// In the case of acceptance: /// All the fields are required. -#[instrument(err, skip(election_event, email_sender, sms_sender))] +#[instrument(skip_all, err)] pub async fn send_template_email_or_sms( hasura_transaction: &Transaction<'_>, user: &User, @@ -653,7 +641,7 @@ pub async fn send_template_email_or_sms( #[cfg(test)] mod tests { - use super::get_variables; + use super::{delivery_audit_message, get_variables}; use sequent_core::services::generate_urls::AuthAction; use sequent_core::types::keycloak::User; use serde_json::json; @@ -693,4 +681,15 @@ mod tests { ); assert_eq!(variables["user"]["attributes"]["empty"], json!([])); } + + #[test] + fn delivery_audit_message_does_not_include_rendered_content() { + let message = delivery_audit_message("email"); + + assert_eq!( + serde_json::from_str::(&message).expect("valid audit JSON"), + json!({"channel": "email"}) + ); + assert!(!message.contains("login_hint__")); + } } From 566bb5a17274462367ab649d82e8ecd593cd4986 Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Wed, 29 Jul 2026 15:45:29 +0000 Subject: [PATCH 11/13] =?UTF-8?q?=F0=9F=90=9E=20Fix=20login=20hints=20neve?= =?UTF-8?q?r=20reaching=20the=20login=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent defects each stopped `login_hint__*` parameters from prefilling the Keycloak username field. `parseLoginHints` iterated `URLSearchParams.entries()` and `Map.keys()` with `for ... of`. The portal compiles with `target: "es5"` and no `downlevelIteration`, so both loops became indexed loops over an iterator whose `length` is `undefined`, and never ran a single iteration. Hints were therefore always empty, so the portal took the plain login path and left the hints in the redirect URI. The unit tests did not catch this because `@swc/jest` builds them for `es2022`. `createLoginUrl` and `createRegisterUrl` return `Promise` in keycloak-js 26, and were passed unawaited to `appendLoginHints`, which threw `Invalid URL` and aborted the redirect. TypeScript reports this, but the webpack build runs `ts-loader` with `transpileOnly`. The voting portal login template dropped stock Keycloak's `value="${(login.username!'')}"`, so the username field rendered empty even when Keycloak had resolved the hint. --- .../sequent.voting-portal/login/login.ftl | 2 +- .../src/providers/AuthContextProvider.tsx | 24 +++++++------------ .../voting-portal/src/utils/loginHints.ts | 12 ++++++---- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.voting-portal/login/login.ftl b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.voting-portal/login/login.ftl index cbe677b846d..fc6c248dffb 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.voting-portal/login/login.ftl +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.voting-portal/login/login.ftl @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- diff --git a/packages/voting-portal/src/providers/AuthContextProvider.tsx b/packages/voting-portal/src/providers/AuthContextProvider.tsx index 08269a80314..e63d22f6331 100644 --- a/packages/voting-portal/src/providers/AuthContextProvider.tsx +++ b/packages/voting-portal/src/providers/AuthContextProvider.tsx @@ -313,28 +313,20 @@ const AuthContextProvider = (props: AuthContextProviderProps) => { } if (Object.keys(loginHints).length > 0) { - window.location.assign( - appendLoginHints( - keycloak.createRegisterUrl(registerOptions), - loginHints - ) - ) + const registerUrl = await keycloak.createRegisterUrl(registerOptions) + window.location.assign(appendLoginHints(registerUrl, loginHints)) return } return await keycloak.register(registerOptions) } else { if (Object.keys(loginHints).length > 0) { - window.location.assign( - appendLoginHints( - keycloak.createLoginUrl({ - ...keycloakInitOptions, - // Stock username forms only understand the standard OIDC hint. - loginHint: loginHints.username, - }), - loginHints - ) - ) + const loginUrl = await keycloak.createLoginUrl({ + ...keycloakInitOptions, + // Stock username forms only understand the standard OIDC hint. + loginHint: loginHints.username, + }) + window.location.assign(appendLoginHints(loginUrl, loginHints)) return } diff --git a/packages/voting-portal/src/utils/loginHints.ts b/packages/voting-portal/src/utils/loginHints.ts index dd3e4b6a839..662da8fd49a 100644 --- a/packages/voting-portal/src/utils/loginHints.ts +++ b/packages/voting-portal/src/utils/loginHints.ts @@ -69,9 +69,11 @@ export const parseLoginHints = (search: string): ParsedLoginHints => { const searchParams = new URLSearchParams(search) const hints = new Map() - for (const [parameterName, value] of searchParams.entries()) { + // The portal is compiled targeting ES5, where `for ... of` over an iterator + // (as opposed to an array) is transpiled into a no-op indexed loop. + searchParams.forEach((value, parameterName) => { if (!parameterName.startsWith(LOGIN_HINT_PREFIX)) { - continue + return } const hintName = parameterName.slice(LOGIN_HINT_PREFIX.length) @@ -85,11 +87,11 @@ export const parseLoginHints = (search: string): ParsedLoginHints => { if (hints.size > MAX_LOGIN_HINT_COUNT) { throw new InvalidLoginHintsError() } - } + }) - for (const hintName of hints.keys()) { + hints.forEach((_value, hintName) => { searchParams.delete(`${LOGIN_HINT_PREFIX}${hintName}`) - } + }) const remainingSearch = searchParams.toString() return { From c68bd8dbb209ece574bcf223627a5cdfb5c8bb5b Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Wed, 29 Jul 2026 15:45:42 +0000 Subject: [PATCH 12/13] =?UTF-8?q?=E2=9C=A8=20Add=20per-attribute=20login?= =?UTF-8?q?=20hint=20prefill=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration fields now decide how they accept a prefilled value through the `loginHintPrefillPolicy` user profile annotation: `EDITABLE` (the default when unannotated), `READ_ONLY` or `IGNORE`. An unrecognised value falls back to `IGNORE`, so a typo never prefills a field. `READ_ONLY` renders the field read-only and rejects a registration whose submitted value differs from the hint, since the rendered attribute is only a browser affordance. The redisplayed form restores the hint value, otherwise the voter is left with a rejected value in a field they can no longer edit. Text inputs use `readonly` rather than `disabled` so the value is still submitted; selects, radios and checkboxes are disabled and mirror their value in a hidden input. Both prefill paths share the same resolution, so the annotation applies to stock and deferred registration alike. --- ...ion_management_election-event_templates.md | 30 +++- .../login/messages/messages_en.properties | 1 + .../login/user-profile-commons.ftl | 25 ++- .../DeferredRegistrationUserCreation.java | 97 +++++++--- .../voter_enrollment/LoginHintPrefill.java | 162 ++++++++++++++++- .../LoginHintRegistrationPrefill.java | 75 +++++--- .../LoginHintPrefillTest.java | 167 ++++++++++++++++++ packages/voting-portal/test/readme.md | 3 + 8 files changed, 501 insertions(+), 59 deletions(-) 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 aaa148439cf..5e64cbe44ec 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 @@ -92,12 +92,32 @@ 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. + :::warning -Prefilled values are editable convenience data, not verified identity claims. -They never bypass authentication, registration validation, or required actions. -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. +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/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 6b0a2bde933..4d5ad93feec 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