From ff88cafa605685b5ca1090ab749777562fecb2cb Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 09:24:55 +0200 Subject: [PATCH 01/12] =?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 9335febf1d4..d28fc4376ce 100644 --- a/packages/voting-portal/src/translations/en.ts +++ b/packages/voting-portal/src/translations/en.ts @@ -320,6 +320,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 e909f8e2cc5..646bc628c6e 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 77d55fb8ff5cbc514d66ac8db120ced56b2e57c2 Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 09:31:01 +0200 Subject: [PATCH 02/12] =?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 d46178645f99877cbc3e20f5f44f1fdb1d509d9e Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 10:30:25 +0200 Subject: [PATCH 03/12] =?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 5ee96d02e8baac6f8a841fd3d8db6fdd6bcc4990 Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 10:34:25 +0200 Subject: [PATCH 04/12] =?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 0bd0e9f120134c7d9ab9545ffb742d443e195fe4 Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 10:37:31 +0200 Subject: [PATCH 05/12] =?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 e7d544cf70e2bbe1485a553574fa8b13882129ec Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 12:01:12 +0200 Subject: [PATCH 06/12] =?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 4f7d76a5419..aa6dc40db83 100644 --- a/packages/Cargo.lock +++ b/packages/Cargo.lock @@ -8622,6 +8622,7 @@ dependencies = [ "tracing-log", "tracing-subscriber", "tracing-tree", + "urlencoding", "uuid", "warp", "wasm-bindgen", diff --git a/packages/sequent-core/Cargo.toml b/packages/sequent-core/Cargo.toml index 68fc949143d..5712bfefa36 100644 --- a/packages/sequent-core/Cargo.toml +++ b/packages/sequent-core/Cargo.toml @@ -80,6 +80,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 } @@ -137,7 +138,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"] 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"] 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 07fa7a07805d5b4b82e530d40dc0743b6d6b780a Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 12:17:05 +0200 Subject: [PATCH 07/12] =?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 557065155df1bf4232c0f0fe7867f2cedbc63cdb Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 12:20:18 +0200 Subject: [PATCH 08/12] =?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 cd368663d3c2d31a95663a391da38513b06e450c Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Thu, 23 Jul 2026 13:47:16 +0200 Subject: [PATCH 09/12] =?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 +- .../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 +++++++------ 12 files changed, 187 insertions(+), 68 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/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 b92222df083f2b4271b7109f2961c5e003f877aa Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Wed, 29 Jul 2026 15:45:29 +0000 Subject: [PATCH 10/12] =?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. (cherry picked from commit 566bb5a17274462367ab649d82e8ecd593cd4986) --- .../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 d1ff6a426ec..539600bccf0 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 @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- aria-invalid="true" /> 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 0033bb6aa8ce822d55c39f31514e13db2794cbdb Mon Sep 17 00:00:00 2001 From: Eduardo Robles Elvira Date: Wed, 29 Jul 2026 15:45:42 +0000 Subject: [PATCH 11/12] =?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. (cherry picked from commit c68bd8dbb209ece574bcf223627a5cdfb5c8bb5b) --- ...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 6d2b1f663ad..2be06a73784 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_en.properties +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_en.properties @@ -4,6 +4,7 @@ doLogIn=LOGIN loginTitle=Login to {0} loginFooter=Powered by Sequent Tech Inc invalidConfirmationValue=Confirmation doesn''t match +loginHintReadOnlyFieldModified=This field was prefilled and cannot be changed system.version=Version: system.hash=Hash: backToLogin=BACK TO LOGIN diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/user-profile-commons.ftl b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/user-profile-commons.ftl index 1e32dec0c42..dda5985b6bf 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/user-profile-commons.ftl +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/user-profile-commons.ftl @@ -6,6 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only <#-- Source: https://github.com/keycloak/keycloak/blob/24.0.0/themes/src/main/resources/theme/base/login/user-profile-commons.ftl --> +<#-- True when the attribute was prefilled from a login hint annotated as READ_ONLY, + in which case the voter must not be able to change the submitted value. --> +<#function isLoginHintReadOnly attributeName> + <#return loginHintReadOnlyAttributes?? && loginHintReadOnlyAttributes?seq_contains(attributeName)> + + <#macro userProfileFormFields> <#assign currentGroup=""> <#assign readonlyElements = []> @@ -183,6 +189,8 @@ SPDX-License-Identifier: AGPL-3.0-only disabled + <#-- readonly rather than disabled so the locked value is still submitted --> + <#if isLoginHintReadOnly(attribute.name)>readonly <#-- Checks for attribute annotations that start with "html-attribute:" and sets them as input attributes --> <#list attribute.annotations as key, value> <#if key?starts_with("html-attribute:")>${key[15..]}=${value} @@ -220,6 +228,7 @@ SPDX-License-Identifier: AGPL-3.0-only