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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/sonarqube.yml
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: mvn -B verify sonar:sonar -f packages/keycloak-extensions/pom.xml
run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:5.7.0.6970:sonar -f packages/keycloak-extensions/pom.xml
-Dsonar.host.url=https://sonarcloud.io
-Dsonar.token=$SONAR_TOKEN
-Dsonar.organization=sequentech
Expand All @@ -216,7 +216,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: mvn -B verify sonar:sonar -f packages/keycloak-extensions/pom.xml
run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:5.7.0.6970:sonar -f packages/keycloak-extensions/pom.xml
-Dsonar.host.url=https://sonarcloud.io
-Dsonar.token=$SONAR_TOKEN
-Dsonar.organization=sequentech
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ jobs:
timeout-minutes: 20
strategy:
matrix:
package: [ui-core, ui-essentials]
package: [ui-core, ui-essentials, keycloak-extensions/sequent-theme]
fail-fast: false
steps:
- name: Check out code
Expand Down
2 changes: 1 addition & 1 deletion beyond
169 changes: 169 additions & 0 deletions docs/docusaurus/docs/07-developers/06-keycloak/structured_pin_login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
---
id: structured_pin_login
title: Structured PIN login
---

<!--
SPDX-FileCopyrightText: 2026 Sequent Tech <legal@sequentech.io>

SPDX-License-Identifier: AGPL-3.0-only
-->

The structured PIN input is an optional presentation for a voter's ordinary
Keycloak password. The other login value remains the username. It does not add
a credential type, change how Keycloak stores or verifies credentials, or add a
date-of-birth field.

## Realm configuration

Set these Keycloak realm attributes on the election event realm:

| Attribute | Values | Default |
| --- | --- | --- |
| `credential-input-policy` | `standard` or `structured` | `standard` |
| `credential-input-pattern` | A structured digit pattern such as `dddd-dddd-dddd-dddd` | `dddd-dddd-dddd-dddd` |

In the current pattern grammar, each `d` represents one required ASCII digit
and a hyphen separates editable groups. A pattern may contain 1–8 groups, each
group may contain 1–12 `d` tokens, and the combined credential length may not
exceed 64 digits. Other characters and future-reserved operators are rejected.

The application validates these values when realm attributes are saved. If
malformed configuration reaches Keycloak through another route, the theme
leaves the ordinary password field usable.

The attributes can be included in deployment realm configuration or entered in
the permission-gated **Keycloak realm attributes** JSON editor for the election
event. For example:

```json
{
"credential-input-policy": "structured",
"credential-input-pattern": "dddd-dddd-dddd-dddd"
}
```

Removing `credential-input-policy`, or setting it to `standard`, restores the
ordinary password field. The setting is realm-wide; this implementation has no
client-level override.

Events configured for the earlier prototype must replace
`credential-input-policy=segmented-numeric` with `structured`, replace
`credential-segment-layout` with `credential-input-pattern`, convert the value
from group sizes to digit tokens (for example, `4-4-4-4` becomes
`dddd-dddd-dddd-dddd`), and remove the old layout attribute. There is
intentionally no compatibility alias.

## Input behavior

The pattern renders as one textbox with the visibility button inside its outer
border. Empty positions display `d`. Entered positions display `*`, except for
the most recently entered digit, which remains visible for one second. The
visibility button reveals or hides every entered digit.

Left and Right select the previous or next group, while Home and End select the
first or last group. Typing after selecting a group replaces it, and completing
a group advances to the next one. Paste accepts ASCII digits with optional
hyphens or ASCII whitespace. Copy, cut, and drop are disabled so the displayed
mask cannot leak or desynchronize the submitted value.
Rejected paste is left unapplied and announced through the control's polite
status region.
Rejected browser replacement or autofill values use a separate, neutral format
message. The message is shown in the credential error area and announced by its
alert role, so both sighted voters and assistive-technology users receive the
same explanation. Submission remains blocked until the voter makes a valid PIN
edit, import, or deletion; editing only the username cannot submit a stale PIN.

The visible control has no form name. On submit, its digit model is copied into
the one ordinary `password` form value without separators. Incomplete input is
blocked in the browser and focuses the first incomplete group. Invalid input,
leading zeroes, and paste never change the Keycloak credential type.

## Accessibility

The structured control is designed and tested against WCAG 2.1 Level AA for
the component and both supported flows. It retains native textbox and button
semantics, associates the visible PIN label and hint with the textbox, exposes
required and invalid states, and announces group progress and validation
errors. Incomplete submission focuses the first incomplete group. Username and
PIN inputs expose the standard `username` and `current-password` purposes.

Every interaction is keyboard-operable. Tab order is PIN textbox, visibility
button, and then the remaining form controls; Left, Right, Home, End,
Backspace, Delete, typing, paste, reveal, and submission do not require a
pointer or keystroke timing. The field and visibility button have distinct
visible focus states, including in forced-colors mode.

Normal text and icons meet their applicable contrast thresholds. The authored
field border and focus/error states have at least 3:1 contrast against the
field background, while text has at least 4.5:1. The 44 CSS-pixel visibility
target exceeds WCAG 2.1 Level AA and also meets its Level AAA target-size
criterion. The control is tested within a 320 CSS-pixel layout (the WCAG 2.1
reflow width) and with the WCAG text-spacing overrides, without loss of content
or functionality.

## Supported login forms

The policy applies to both:

- the normal `sequent.voting-portal` Keycloak login page; and
- the deferred voter-registration form when its authenticator has
`form-mode=LOGIN` and `password-required=true`.

Actual registration (`form-mode=REGISTRATION`) is unchanged: it continues to
show password confirmation, the strength indicator, and Keycloak's password
creation-policy validation. A deferred LOGIN form with
`password-required=false` also remains unchanged.

Authentication failures use the same generic message for an unknown username,
incorrect PIN, disabled voter, lockout, or expired credential. Operational
errors that require a different action, such as a failed reCAPTCHA challenge,
retain their specific message.
Early password failures perform Keycloak's configured dummy hash so user lookup,
disabled-user, empty-password, and temporary-lockout paths do not omit the
realm's password-hashing cost.

## Password policy and provisioning

Configure a numeric-compatible Keycloak password policy for realms that create
PIN credentials, for example `length(16) and maxLength(16)` for
`dddd-dddd-dddd-dddd`. Password policies are creation-time rules; the deferred
LOGIN flow does not re-apply them after Keycloak has validated an existing
credential. This applies to every deferred LOGIN form, whether its presentation
is `standard` or `structured`. Credential imports that supply password hashes may bypass
creation-time policy checks, so provisioning must validate the configured
length and format.

Keep Keycloak brute-force protection enabled. The deferred LOGIN flow records
the attempted username so its failures participate in Keycloak's normal
failure counting and lockout behavior.

## Localization overrides

The theme supplies defaults for these message keys:

- `structuredCredentialLabel`
- `structuredCredentialHint`
- `structuredCredentialError`
- `structuredCredentialGroupStatus`
- `structuredCredentialPasteError`
- `structuredCredentialFormatError`
- `showStructuredCredential`
- `hideStructuredCredential`

Override them per locale using **Realm Settings → Localization** in Keycloak.
`structuredCredentialGroupStatus` receives the one-based group number as `{0}`,
the number of groups as `{1}`, entered digits as `{2}`, and group size as `{3}`.
Realm localization overrides take precedence over theme defaults.

## Rollout checklist

1. Confirm the event realm uses the `sequent.voting-portal` login theme.
2. Confirm provisioned usernames and numeric PIN passwords match the selected
pattern.
3. Configure a PIN-compatible realm password policy.
4. If using registration-as-login, confirm the deferred authenticator uses
`form-mode=LOGIN` and `password-required=true`.
5. Enable the two realm attributes and test keyboard navigation, leading
zeroes, paste, timed masking, show/hide, generic failures, and lockout before
opening voting.
1 change: 1 addition & 0 deletions packages/Dockerfile.keycloak
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ ARG OID4VP_SHA256=f6cd968bc77fec4f8e1417d3caeefe4920941e01b119f152f1c9186bbd2b2e

# Start with a Maven base image to compile the Keycloak provider extensions.
FROM maven:3-amazoncorretto-17 AS spis-build
RUN dnf install -y findutils && dnf clean all
COPY ./keycloak-extensions/ /build/context/packages/keycloak-extensions/
WORKDIR /build/context
RUN --mount=from=beyond,source=.,target=/build/beyond-context,readonly \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,7 @@ protected boolean validateForm(
currentTime, passwordExpirationInt);
context.failureChallenge(
AuthenticationFlowError.INVALID_CREDENTIALS,
context
.form()
.setError(Messages.INVALID_PASSWORD)
.createErrorPage(Response.Status.BAD_REQUEST));
challenge(context, Messages.INVALID_PASSWORD, Validation.FIELD_PASSWORD));
return false;
}

Expand Down
12 changes: 12 additions & 0 deletions packages/keycloak-extensions/sequent-theme/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"private": true,
"name": "@sequentech/keycloak-theme-tests",
"version": "0.0.0",
"license": "AGPL-3.0-only",
"scripts": {
"test": "node --test src/test/js/*.test.cjs"
},
"devDependencies": {
"jsdom": "^24.1.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2026 Sequent Tech <legal@sequentech.io>

SPDX-License-Identifier: AGPL-3.0-only
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,11 @@ locale_tl=Tagalog
locale_tr=Türkçe
locale_uk=Українська
locale_zh-CN=中文简体
structuredCredentialLabel=PIN
structuredCredentialHint=Introduïu el PIN de la vostra carta d''informació electoral.
structuredCredentialError=El nom d''usuari o el PIN són incorrectes.
structuredCredentialGroupStatus=Grup de PIN {0} de {1}, {2} de {3} dígits introduïts
structuredCredentialPasteError=El valor enganxat no coincideix amb el format del PIN.
structuredCredentialFormatError=El valor introduït no coincideix amb el format del PIN.
showStructuredCredential=Mostra el PIN
hideStructuredCredential=Amaga el PIN
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,11 @@ locale_tl=Tagalog
locale_tr=Türkçe
locale_uk=Українська
locale_zh-CN=中文简体
structuredCredentialLabel=PIN
structuredCredentialHint=Enter the PIN from your Voter Information Letter.
structuredCredentialError=Incorrect username or PIN.
structuredCredentialGroupStatus=PIN group {0} of {1}, {2} of {3} digits entered
structuredCredentialPasteError=The pasted value does not match the PIN format.
structuredCredentialFormatError=The entered value does not match the PIN format.
showStructuredCredential=Show PIN
hideStructuredCredential=Hide PIN
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,11 @@ locale_tl=Tagalog
locale_tr=Türkçe
locale_uk=Українська
locale_zh-CN=中文简体
structuredCredentialLabel=PIN
structuredCredentialHint=Introduzca el PIN de su carta de información para votantes.
structuredCredentialError=El nombre de usuario o el PIN son incorrectos.
structuredCredentialGroupStatus=Grupo de PIN {0} de {1}, {2} de {3} dígitos introducidos
structuredCredentialPasteError=El valor pegado no coincide con el formato del PIN.
structuredCredentialFormatError=El valor introducido no coincide con el formato del PIN.
showStructuredCredential=Mostrar PIN
hideStructuredCredential=Ocultar PIN
Original file line number Diff line number Diff line change
Expand Up @@ -525,4 +525,12 @@ logoutConfirmHeader=Saioa itxi nahi duzu?
doLogout=Itxi saioa

readOnlyUsernameMessage=Ezin duzu zure erabiltzaile-izena eguneratu irakurtzeko soilik baita.
error-invalid-multivalued-size={0} atributuak gutxienez {1} eta gehienez {2} balio izan behar ditu.
error-invalid-multivalued-size={0} atributuak gutxienez {1} eta gehienez {2} balio izan behar ditu.
structuredCredentialLabel=PINa
structuredCredentialHint=Sartu hauteslearen informazio-gutuneko PINa.
structuredCredentialError=Erabiltzaile-izena edo PINa ez da zuzena.
structuredCredentialGroupStatus=PINaren {0}. taldea, {1} taldetik; {3} digitutik {2} sartuta
structuredCredentialPasteError=Itsatsitako balioa ez dator bat PINaren formatuarekin.
structuredCredentialFormatError=Sartutako balioa ez dator bat PINaren formatuarekin.
showStructuredCredential=Erakutsi PINa
hideStructuredCredential=Ezkutatu PINa
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
loginAccountTitle=Connectez-vous au Portail d'Administration
loginAccountTitle=Connectez-vous au Portail d''Administration
identity-provider-login-label=OU SE CONNECTER AVEC
backToLogin=RETOUR À LA CONNEXION
backToApplication=RETOUR À L'APPLICATION
backToApplication=RETOUR À L''APPLICATION
doLogIn=SE CONNECTER
loginTitle=Connectez-vous à {0}
loginFooter=Propulsé par Sequent Tech Inc
Expand Down Expand Up @@ -38,3 +38,11 @@ locale_tl=Tagalog
locale_tr=Türkçe
locale_uk=Українська
locale_zh-CN=中文简体
structuredCredentialLabel=Code PIN
structuredCredentialHint=Saisissez le code PIN figurant sur votre lettre d''information électorale.
structuredCredentialError=Le nom d''utilisateur ou le code PIN est incorrect.
structuredCredentialGroupStatus=Groupe de code PIN {0} sur {1}, {2} chiffres saisis sur {3}
structuredCredentialPasteError=La valeur collée ne correspond pas au format du code PIN.
structuredCredentialFormatError=La valeur saisie ne correspond pas au format du code PIN.
showStructuredCredential=Afficher le code PIN
hideStructuredCredential=Masquer le code PIN
Original file line number Diff line number Diff line change
Expand Up @@ -537,4 +537,12 @@ logoutConfirmHeader=Queres saír?
doLogout=Pechar sesión

readOnlyUsernameMessage=Non podes actualizar o teu nome de usuario xa que é só de lectura.
error-invalid-multivalued-size=O atributo {0} debe ter polo menos {1} e como máximo {2} valores.
error-invalid-multivalued-size=O atributo {0} debe ter polo menos {1} e como máximo {2} valores.
structuredCredentialLabel=PIN
structuredCredentialHint=Introduza o PIN da súa carta de información electoral.
structuredCredentialError=O nome de usuario ou o PIN son incorrectos.
structuredCredentialGroupStatus=Grupo de PIN {0} de {1}, {2} de {3} díxitos introducidos
structuredCredentialPasteError=O valor pegado non coincide co formato do PIN.
structuredCredentialFormatError=O valor introducido non coincide co formato do PIN.
showStructuredCredential=Mostrar o PIN
hideStructuredCredential=Agochar o PIN
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,12 @@ locale_th=ไทย
locale_tl=Tagalog
locale_tr=Türkçe
locale_uk=Українська
locale_zh-CN=中文简体
locale_zh-CN=中文简体
structuredCredentialLabel=PIN
structuredCredentialHint=Voer de PIN uit uw kiezersinformatiebrief in.
structuredCredentialError=De gebruikersnaam of PIN is onjuist.
structuredCredentialGroupStatus=PIN-groep {0} van {1}, {2} van {3} cijfers ingevoerd
structuredCredentialPasteError=De geplakte waarde komt niet overeen met het PIN-formaat.
structuredCredentialFormatError=De ingevoerde waarde komt niet overeen met het PIN-formaat.
showStructuredCredential=PIN tonen
hideStructuredCredential=PIN verbergen
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,11 @@ locale_tl=Tagalog
locale_tr=Türkçe
locale_uk=Українська
locale_zh-CN=中文简体
structuredCredentialLabel=PIN
structuredCredentialHint=Ilagay ang PIN mula sa liham ng impormasyon para sa botante.
structuredCredentialError=Mali ang username o PIN.
structuredCredentialGroupStatus=Grupo ng PIN {0} sa {1}, {2} sa {3} digit ang nailagay
structuredCredentialPasteError=Hindi tugma sa format ng PIN ang na-paste na halaga.
structuredCredentialFormatError=Hindi tugma sa format ng PIN ang inilagay na halaga.
showStructuredCredential=Ipakita ang PIN
hideStructuredCredential=Itago ang PIN
Loading
Loading