From c0e2758e3bc7c3be61ebe1dfc2cb91a20a592b2d Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 20 Aug 2026 20:41:42 -0600 Subject: [PATCH 1/5] feat: handle WebAuthn step-up (SUF-02) on sensitive operations (#75) Adds the client handling and E2E coverage for the framework's opt-in WebAuthn step-up primitive (library 5.3.3, #365). Off unless user.security.stepUp.enabled is true, so existing demo flows are unchanged. Sensitive operations on passkey-only accounts return 401 until a recent passkey assertion exists: POST /user/setPassword (JSONResponse code 6) and passkey delete/rename (GenericResponse error "step-up-required"). On either shape the new static/js/user/step-up.js prompts with a modal, re-runs the existing passkey login ceremony (authenticateWithPasskey), and retries once. Re-running /login/webauthn mid-session rotates the CSRF token (default session repository) and changes the session id; since step-up stays on the page instead of navigating, step-up.js refreshes the token from the new GET /csrf endpoint before retrying. Wired into update-password.js (setPassword) and webauthn-manage.js (delete, rename), reading the CSRF token live so the retry uses the fresh value. E2E: new application-step-up.yml profile and chromium-step-up Playwright project (@step-up-enabled, excluded from the default and mfa projects). step-up-flow.spec covers the enabled path (ceremony then retry succeeds, for setPassword and rename) and the negative path (absent WEBAUTHN factor returns 401 and runs no ceremony). application-playwright-test.yml now also pins user.webauthn.rpId to localhost and allowedOrigins to http://localhost:8080 so the virtual-authenticator ceremonies work even when a developer's application-local.yml points WebAuthn at an ngrok host; this also hardens the existing mfa E2E under local,playwright-test. Docs: AUTHENTICATION.md gains a step-up section; CONFIGURATION.md and TESTING.md document the profile and the new Playwright project. Verified: chromium-step-up 3/3 pass; change-password + auth-methods 16/16 pass on a non-step-up server (no regression); ./gradlew test green against 5.3.3. Claude-Session: https://claude.ai/code/session_016iMnES4LsPse9LvJvyRpdN --- build.gradle | 3 +- docs/AUTHENTICATION.md | 39 ++++ docs/CONFIGURATION.md | 6 +- docs/TESTING.md | 13 +- playwright/playwright.config.ts | 20 +- playwright/tests/step-up/step-up-flow.spec.ts | 195 ++++++++++++++++++ .../demo/controller/CsrfController.java | 37 ++++ .../resources/application-playwright-test.yml | 7 + src/main/resources/application-step-up.yml | 30 +++ src/main/resources/static/js/user/step-up.js | 175 ++++++++++++++++ .../static/js/user/update-password.js | 25 ++- .../static/js/user/webauthn-manage.js | 54 +++-- 12 files changed, 575 insertions(+), 29 deletions(-) create mode 100644 playwright/tests/step-up/step-up-flow.spec.ts create mode 100644 src/main/java/com/digitalsanctuary/spring/demo/controller/CsrfController.java create mode 100644 src/main/resources/application-step-up.yml create mode 100644 src/main/resources/static/js/user/step-up.js diff --git a/build.gradle b/build.gradle index b161355..6886405 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,8 @@ repositories { dependencies { // DigitalSanctuary Spring User Framework - implementation 'com.digitalsanctuary:ds-spring-user-framework:5.3.1' + // 5.3.3 adds the WebAuthn step-up primitive (SUF-02, library #365) this demo exercises. + implementation 'com.digitalsanctuary:ds-spring-user-framework:5.3.3' // WebAuthn support (Passkey authentication) implementation 'org.springframework.security:spring-security-webauthn' diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index dc765bb..6449c5b 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -86,6 +86,45 @@ sets the flag true where the flow has to be demonstrable (`application-local.yml `application-mfa.yml:24`, `application-playwright-test.yml:35`) and leaves it at the secure default `false` in `prd` (`application-prd.yml:50-52`). +## WebAuthn step-up (SUF-02) + +The `step-up` profile (`application-step-up.yml`) turns on the framework's step-up primitive with +`user.security.stepUp.enabled=true`. It registers the built-in `StepUpService`, so on a passkey-only +account the credential-altering operations require a recent WebAuthn assertion first: + +- `POST /user/setPassword` returns `401` with `JSONResponse` code `6`. +- passkey delete (`DELETE /user/webauthn/credentials/{id}`) and rename (`PUT .../{id}/label`) return + `401` with `GenericResponse` `error: "step-up-required"`. + +Step-up is a freshness requirement on the `FACTOR_WEBAUTHN` authority Spring Security already issues, not +a bespoke ceremony: the client re-runs the ordinary passkey login (the same +`authenticateWithPasskey()` used at `/user/login.html`) while still logged in, which refreshes that factor +on the session, then retries the original call. There is no separate step-up endpoint or token. + +The client handling lives in [`step-up.js`](../src/main/resources/static/js/user/step-up.js), wired into +the set-password ([`update-password.js`](../src/main/resources/static/js/user/update-password.js)) and +passkey delete/rename ([`webauthn-manage.js`](../src/main/resources/static/js/user/webauthn-manage.js)) +flows. On either `401` shape it shows a modal warning that a passkey check is coming (so the browser's +authenticator dialog is not a surprise), runs the ceremony, and retries once. Because re-running +`/login/webauthn` mid-session triggers Spring Security's authentication success handling — session-id +change (fixation protection) and, with the default session-based repository, CSRF token rotation — the +retry would otherwise fail with a stale token. `step-up.js` fetches the rotated token from `GET /csrf` +([`CsrfController`](../src/main/java/com/digitalsanctuary/spring/demo/controller/CsrfController.java)) +and updates the page's `` tags before retrying. (The normal login path sidesteps this by fully +navigating to a fresh page; step-up deliberately stays put.) + +Enabling step-up also gates passkey enrollment (`POST /webauthn/register`) on a recent authentication by +any factor (`enrollmentTtlSeconds`, default `600`), and enables factor merging so the fresh factor merges +onto the session instead of replacing its authorities. Social-login (OAuth-only) accounts have no passkey +and cannot satisfy `WEBAUTHN` step-up; for them `setPassword` falls back to +`allowInitialPasswordSetWithoutStepUp`, exactly as before. + +The `chromium-step-up` Playwright project +([`step-up-flow.spec.ts`](../playwright/tests/step-up/step-up-flow.spec.ts)) verifies the enabled path in a +browser (ceremony then retry succeeds) and the negative path (absent `WEBAUTHN` factor returns `401` and +runs no ceremony). Run it with +`APP_PROFILES=local,playwright-test,step-up npx playwright test --project=chromium-step-up`. + ## MFA The `mfa` profile turns on `user.mfa.enabled` (`application-mfa.yml:20`), `false` in the base config diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a93bedb..8c03b98 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -16,7 +16,7 @@ values for one scenario (local dev, production, tests, and so on). For the full `local`, `dev`, `prd`, and `docker-keycloak` are base profiles you choose directly, one at a time, the way the command above chooses `local`. `test` is not chosen by hand: `./gradlew test` applies it automatically. `playwright-test` is meant to be combined with a base profile rather than run alone -(see its row below). `mfa` and `registration-guard` are opt-in add-ons with no base settings of their +(see its row below). `mfa`, `step-up`, and `registration-guard` are opt-in add-ons with no base settings of their own; combine one with a base profile by listing both, comma-separated, in `--spring.profiles.active` (Spring Boot applies later profiles' properties over earlier ones when the same key is set in both). If you omit `--args` entirely, `bootRun` still defaults to `local`: `build.gradle:118-123` sets @@ -32,11 +32,13 @@ If you omit `--args` entirely, `bootRun` still defaults to `local`: `build.gradl | `playwright-test` | [`application-playwright-test.yml`](../src/main/resources/application-playwright-test.yml) | Playwright E2E runs; enables the Test API (`TestDataController`, `TestApiSecurityConfig`, localhost-only) | Disables verification/reset emails, points `spring.datasource.*` at the same local MariaDB the `local` profile uses, pins `appUrl` to `http://localhost:8080`, `allowInitialPasswordSetWithoutStepUp: true`, MFA off, and restates `user.security.unprotectedURIs` in full (a list property is replaced wholesale, not merged, so this copy has to match the base list in `application.yml:160`) | Combine with a base profile, e.g. `local,playwright-test` (see [TESTING.md](TESTING.md)) | | `docker-keycloak` | [`application-docker-keycloak.yml`](../src/main/resources/application-docker-keycloak.yml) (tracked; holds only `${...}` placeholders, nothing to copy) | OIDC login against the bundled Keycloak stack; see [`keycloak/README.md`](../keycloak/README.md) and [AUTHENTICATION.md#keycloak](AUTHENTICATION.md#keycloak) for the full walkthrough | Adds the Keycloak OAuth2 client/provider from `DS_SPRING_USER_KEYCLOAK_*` env vars (deliberately no `issuer-uri`), insecure session cookie | `--spring.profiles.active=docker-keycloak`, normally set for you as `SPRING_PROFILES_ACTIVE` inside `docker-compose-keycloak.yml` | | `mfa` | [`application-mfa.yml`](../src/main/resources/application-mfa.yml) | Add-on: require PASSWORD + WEBAUTHN | `user.mfa.enabled: true` (base `application.yml:126` has it `false`); once enabled, the framework auto-unprotects the configured MFA entry-point URIs at runtime, including the challenge page, so a partially-authenticated user can reach them; the profile's yml additionally adds the passkey enrollment endpoints `/webauthn/register/options` and `/webauthn/register` to `unprotectedURIs` (line 25) so that user can register their first passkey; `allowInitialPasswordSetWithoutStepUp: true` | Combine with a base profile, e.g. `local,mfa` | +| `step-up` | [`application-step-up.yml`](../src/main/resources/application-step-up.yml) | Add-on: require a recent passkey assertion for credential-altering operations on passkey-only accounts (SUF-02) | `user.security.stepUp.enabled: true` (base default is `false`), `ttlSeconds: 120`, `factors: [WEBAUTHN]`; this registers the framework's built-in `StepUpService`, so `POST /user/setPassword` and passkey delete/rename return `401` until a fresh `WEBAUTHN` factor exists, and passkey enrollment is gated on a recent authentication. See [AUTHENTICATION.md#webauthn-step-up-suf-02](AUTHENTICATION.md#webauthn-step-up-suf-02) | Combine with a base profile, e.g. `local,step-up` | | `registration-guard` | none (no yml; `@Profile("registration-guard")` on [`DomainRegistrationGuard`](../src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java)) | Add-on: domain-restricted registration demo | Activates a `RegistrationGuard` bean that restricts form/passwordless registration to one email domain (`registration.guard.allowed-domain`, default `@example.com`); OAuth2/OIDC registration is unaffected | Combine with a base profile, e.g. `local,registration-guard` | See [AUTHENTICATION.md](AUTHENTICATION.md) for the mechanics behind `mfa` ([#mfa](AUTHENTICATION.md#mfa)), `docker-keycloak` ([#keycloak](AUTHENTICATION.md#keycloak)), -WebAuthn passkeys ([#passkeys](AUTHENTICATION.md#passkeys)), and `registration-guard` +WebAuthn passkeys ([#passkeys](AUTHENTICATION.md#passkeys)), `step-up` +([#webauthn-step-up-suf-02](AUTHENTICATION.md#webauthn-step-up-suf-02)), and `registration-guard` ([#registration-guard](AUTHENTICATION.md#registration-guard)). ## Getting started locally diff --git a/docs/TESTING.md b/docs/TESTING.md index 70d4d70..81be2ee 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -100,13 +100,22 @@ disables verification/reset emails (tests fetch tokens via the Test API instead) "set initial password" flow works without a `StepUpService` bean. The `chromium`, `firefox`, `webkit`, `Mobile Chrome`, and `Mobile Safari` projects skip specs -tagged `@mfa-enabled` (`grepInvert`); a separate `chromium-mfa` project runs only those specs, -against a server started with the `mfa` profile added: +tagged `@mfa-enabled` and `@step-up-enabled` (`grepInvert`); separate Chromium-only projects run +those, each against a server started with the matching add-on profile. Both use the CDP virtual +authenticator, so they are Chromium-only. ```bash +# MFA flow (@mfa-enabled) APP_PROFILES=local,playwright-test,mfa npx playwright test --project=chromium-mfa + +# WebAuthn step-up / SUF-02 (@step-up-enabled) +APP_PROFILES=local,playwright-test,step-up npx playwright test --project=chromium-step-up ``` +The `playwright-test` profile also pins `user.webauthn.rpId=localhost` and +`allowedOrigins=http://localhost:8080`, so the virtual authenticator ceremonies work even when a +developer's `application-local.yml` points WebAuthn at an ngrok host. + **Test API**: [`TestDataController`](../src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java) exposes `/api/test/*` (create/enable/unlock/delete a user, fetch verification and password-reset diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index 821c5ac..b70b6bd 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -93,32 +93,32 @@ export default defineConfig({ projects: [ { name: 'chromium', - grepInvert: /@mfa-enabled/, + grepInvert: /@mfa-enabled|@step-up-enabled/, use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', - grepInvert: /@mfa-enabled/, + grepInvert: /@mfa-enabled|@step-up-enabled/, use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', - grepInvert: /@mfa-enabled/, + grepInvert: /@mfa-enabled|@step-up-enabled/, use: { ...devices['Desktop Safari'] }, }, /* Test against mobile viewports */ { name: 'Mobile Chrome', - grepInvert: /@mfa-enabled/, + grepInvert: /@mfa-enabled|@step-up-enabled/, use: { ...devices['Pixel 5'] }, }, { name: 'Mobile Safari', - grepInvert: /@mfa-enabled/, + grepInvert: /@mfa-enabled|@step-up-enabled/, use: { ...devices['iPhone 12'] }, }, @@ -128,6 +128,16 @@ export default defineConfig({ grep: /@mfa-enabled/, use: { ...devices['Desktop Chrome'] }, }, + + /* Step-up (SUF-02) tests: Chromium only (CDP virtual authenticator), step-up-enabled server required. + * Run with: + * APP_PROFILES=local,playwright-test,step-up npx playwright test --project=chromium-step-up + * (the step-up profile must come last so its overrides win). */ + { + name: 'chromium-step-up', + grep: /@step-up-enabled/, + use: { ...devices['Desktop Chrome'] }, + }, ], /* Run your local dev server before starting the tests */ diff --git a/playwright/tests/step-up/step-up-flow.spec.ts b/playwright/tests/step-up/step-up-flow.spec.ts new file mode 100644 index 0000000..9d7ed21 --- /dev/null +++ b/playwright/tests/step-up/step-up-flow.spec.ts @@ -0,0 +1,195 @@ +import { test, expect, generateTestUser, TestUser } from '../../src/fixtures'; +import type { Page } from '@playwright/test'; + +/** + * WebAuthn step-up (SUF-02) E2E, using Chromium's CDP virtual authenticator. + * + * Requires the app to run with step-up enabled: + * APP_PROFILES=local,playwright-test,step-up npx playwright test --project=chromium-step-up + * (the step-up profile must come last so its overrides win). + * + * Tagged @step-up-enabled so the default and MFA projects skip it: those servers run with step-up off. + * + * The account under test is passkey-only (no password). Its session, right after registration, carries + * a fresh FACTOR_PASSWORD but no FACTOR_WEBAUTHN, so credential-altering operations are refused with 401 + * until the passkey ceremony stamps a fresh WEBAUTHN factor. That makes both paths deterministic with no + * reliance on the TTL elapsing. + */ + +/** + * Enable a CDP WebAuthn virtual authenticator that auto-approves create()/get() so no human touch is + * needed. Mirrors playwright/tests/mfa/mfa-flow.spec.ts. + */ +async function setupVirtualAuthenticator(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send('WebAuthn.enable'); + await cdp.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); +} + +/** + * Register a passkey-only account and enroll its first passkey, leaving the browser session + * authenticated with no fresh WEBAUTHN factor. Returns once a passkey is enrolled. + */ +async function createPasswordlessUserWithPasskey(page: Page, user: TestUser): Promise { + // An unauthenticated page first, for its CSRF token and a session to auto-login into. + await page.goto('/user/register.html'); + await page.waitForLoadState('domcontentloaded'); + + const registered = await page.evaluate(async (u) => { + const csrfHeader = document.querySelector('meta[name="_csrf_header"]')!.getAttribute('content')!; + const csrfToken = document.querySelector('meta[name="_csrf"]')!.getAttribute('content')!; + const response = await fetch('/user/registration/passwordless', { + method: 'POST', + headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken }, + body: JSON.stringify({ firstName: u.firstName, lastName: u.lastName, email: u.email }), + }); + return { ok: response.ok, body: await response.json() }; + }, user); + + expect(registered.ok, `passwordless registration failed: ${JSON.stringify(registered.body)}`).toBe(true); + + // Passwordless registration auto-logs-in (enabled account). Reload an authenticated page so its CSRF + // meta reflects the logged-in session, then enroll the first passkey via the app's own module. + await page.goto('/user/update-user.html'); + await page.waitForLoadState('domcontentloaded'); + await page.evaluate(async () => { + const { registerPasskey } = await import('/js/user/webauthn-register.js'); + await registerPasskey('e2e-step-up-passkey'); + }); +} + +/** Read the current credential id list via the management API. */ +async function getCredentialIds(page: Page): Promise { + return page.evaluate(async () => { + const csrfHeader = document.querySelector('meta[name="_csrf_header"]')!.getAttribute('content')!; + const csrfToken = document.querySelector('meta[name="_csrf"]')!.getAttribute('content')!; + const response = await fetch('/user/webauthn/credentials', { headers: { [csrfHeader]: csrfToken } }); + const creds = await response.json(); + return creds.map((c: { id: string }) => c.id); + }); +} + +test.describe('WebAuthn Step-Up @step-up-enabled', () => { + test('setting a password on a passkey-only account requires step-up, then succeeds after the ceremony', async ({ + page, + cleanupEmails, + }) => { + const user = generateTestUser('stepup-setpw'); + cleanupEmails.push(user.email); + + await setupVirtualAuthenticator(page); + await createPasswordlessUserWithPasskey(page, user); + + // Set-password page opens in "set" mode for a passwordless account. + await page.goto('/user/update-password.html'); + await expect(page.locator('#setPasswordInfo')).toBeVisible(); + + await page.locator('#newPassword').fill(user.password); + await page.locator('#confirmPassword').fill(user.password); + await page.locator('#updatePasswordForm button[type="submit"]').click(); + + // The server refuses with 401 (code 6); the client shows the step-up modal rather than a raw error. + const verifyBtn = page.locator('#stepUpVerifyBtn'); + await expect(verifyBtn).toBeVisible(); + + // The ceremony (auto-approved by the virtual authenticator) refreshes the WEBAUTHN factor; the client + // refreshes the rotated CSRF token and retries the setPassword call, which now succeeds. + await verifyBtn.click(); + await expect(page.locator('#globalMessage')).toHaveClass(/alert-success/, { timeout: 15000 }); + + // The account now has a password (auth-methods wraps its fields in a `data` envelope). + const auth = await page.evaluate(async () => { + const response = await fetch('/user/auth-methods'); + return response.json(); + }); + expect(auth.data.hasPassword).toBe(true); + }); + + test('renaming a passkey on a passkey-only account requires step-up, then succeeds after the ceremony', async ({ + page, + cleanupEmails, + }) => { + const user = generateTestUser('stepup-rename'); + cleanupEmails.push(user.email); + + await setupVirtualAuthenticator(page); + await createPasswordlessUserWithPasskey(page, user); + + await page.goto('/user/update-user.html'); + await page.locator('#passkeys-list button[data-action="rename"]').first().waitFor(); + + await page.locator('#passkeys-list button[data-action="rename"]').first().click(); + await page.locator('#renamePasskeyInput').fill('renamed-by-e2e'); + await page.locator('#confirmRenameButton').click(); + + // Step-up modal, then ceremony, then the rename retry succeeds. + const verifyBtn = page.locator('#stepUpVerifyBtn'); + await expect(verifyBtn).toBeVisible(); + await verifyBtn.click(); + + await expect(page.locator('#passkeyMessage')).toHaveClass(/alert-success/, { timeout: 15000 }); + await expect(page.locator('#passkeys-list')).toContainText('renamed-by-e2e'); + }); + + test('sensitive operations are refused with 401 step-up and run no ceremony when the WEBAUTHN factor is absent', async ({ + page, + cleanupEmails, + }) => { + const user = generateTestUser('stepup-negative'); + cleanupEmails.push(user.email); + + await setupVirtualAuthenticator(page); + await createPasswordlessUserWithPasskey(page, user); + + await page.goto('/user/update-user.html'); + const [credentialId] = await getCredentialIds(page); + expect(credentialId).toBeTruthy(); + + // Raw calls with no ceremony: the server gate alone must produce the two documented 401 shapes. + const results = await page.evaluate(async (credId) => { + const csrfHeader = document.querySelector('meta[name="_csrf_header"]')!.getAttribute('content')!; + const csrfToken = document.querySelector('meta[name="_csrf"]')!.getAttribute('content')!; + const headers = { 'Content-Type': 'application/json', [csrfHeader]: csrfToken }; + + const setPassword = await fetch('/user/setPassword', { + method: 'POST', + headers, + body: JSON.stringify({ newPassword: 'Test@Pass123!', confirmPassword: 'Test@Pass123!' }), + }); + const rename = await fetch(`/user/webauthn/credentials/${credId}/label`, { + method: 'PUT', + headers, + body: JSON.stringify({ label: 'should-not-apply' }), + }); + const del = await fetch(`/user/webauthn/credentials/${credId}`, { + method: 'DELETE', + headers: { [csrfHeader]: csrfToken }, + }); + + return { + setPassword: { status: setPassword.status, body: await setPassword.json() }, + rename: { status: rename.status, body: await rename.json() }, + del: { status: del.status, body: await del.json() }, + }; + }, credentialId); + + // setPassword: JSONResponse code 6. + expect(results.setPassword.status).toBe(401); + expect(results.setPassword.body.code).toBe(6); + + // delete / rename: GenericResponse error "step-up-required". + expect(results.rename.status).toBe(401); + expect(results.rename.body.error).toBe('step-up-required'); + expect(results.del.status).toBe(401); + expect(results.del.body.error).toBe('step-up-required'); + }); +}); diff --git a/src/main/java/com/digitalsanctuary/spring/demo/controller/CsrfController.java b/src/main/java/com/digitalsanctuary/spring/demo/controller/CsrfController.java new file mode 100644 index 0000000..3e119b8 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/demo/controller/CsrfController.java @@ -0,0 +1,37 @@ +package com.digitalsanctuary.spring.demo.controller; + +import java.util.Map; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Exposes the current session's CSRF token to authenticated, same-origin JavaScript. + * + *

+ * The step-up ceremony (SUF-02) re-runs {@code /login/webauthn} while the user is already logged in. Spring Security's default + * session-based CSRF repository rotates the token on that authentication success, but the page is not reloaded, so its {@code } + * tags still carry the pre-ceremony token. {@code step-up.js} calls this endpoint afterwards to pick up the rotated token before it + * retries the original request. See {@code src/main/resources/static/js/user/step-up.js}. + *

+ * + *

+ * This is a GET (no CSRF required to call it) and leans on Spring's {@code CsrfToken} argument resolver, which materializes the + * deferred token from the request. It returns only to the caller's own session, so it discloses nothing an attacker could not already + * obtain from a rendered page in that same session. + *

+ */ +@RestController +public class CsrfController { + + /** + * Returns the current CSRF token along with the header and parameter names the client should send it under. + * + * @param token the session's CSRF token, resolved by Spring + * @return a JSON object with {@code token}, {@code headerName}, and {@code parameterName} + */ + @GetMapping("/csrf") + public Map csrf(CsrfToken token) { + return Map.of("token", token.getToken(), "headerName", token.getHeaderName(), "parameterName", token.getParameterName()); + } +} diff --git a/src/main/resources/application-playwright-test.yml b/src/main/resources/application-playwright-test.yml index 26de276..f09212e 100644 --- a/src/main/resources/application-playwright-test.yml +++ b/src/main/resources/application-playwright-test.yml @@ -19,6 +19,13 @@ spring: # Enable test API endpoints by adding them to unprotected URIs user: + webauthn: + # E2E runs against http://localhost:8080 with a CDP virtual authenticator, so the WebAuthn RP identity + # must be localhost. Pin it here so it wins over any ngrok rpId/origin a developer's application-local.yml + # sets (this profile is typically activated as local,playwright-test). Without this the browser rejects + # create()/get() with "the relying party ID is not a registrable domain suffix of the current domain". + rpId: localhost + allowedOrigins: http://localhost:8080 mfa: enabled: false registration: diff --git a/src/main/resources/application-step-up.yml b/src/main/resources/application-step-up.yml new file mode 100644 index 0000000..92d43ba --- /dev/null +++ b/src/main/resources/application-step-up.yml @@ -0,0 +1,30 @@ +# Step-Up Re-Authentication Demo Profile (SUF-02) +# +# Turns on the framework's WebAuthn step-up primitive: credential-altering operations on a +# passkey-only account require a recent passkey assertion before they proceed. +# +# POST /user/setPassword ......... 401 (JSONResponse code 6) until a fresh WEBAUTHN factor exists +# passkey delete / rename ........ 401 (error "step-up-required") likewise +# +# Run alongside your normal profile, e.g.: +# ./gradlew bootRun --args='--spring.profiles.active=local,step-up' +# +# The client handling lives in src/main/resources/static/js/user/step-up.js: on a 401 it re-runs the +# ordinary passkey login ceremony (which refreshes the session's WEBAUTHN freshness) and retries the +# original request. There is no separate step-up endpoint or token. +# +# Notes: +# - Enabling step-up registers the framework's built-in StepUpService, so setPassword is governed by +# step-up rather than by allowInitialPasswordSetWithoutStepUp (which only applies when no +# StepUpService bean is present). +# - Enabling step-up also enables factor merging, so re-authenticating mid-session merges the fresh +# WEBAUTHN factor onto the existing session instead of replacing its authorities. +# - Social-login (OAuth-only) accounts have no passkey and cannot satisfy WEBAUTHN step-up; for them +# setPassword falls back to allowInitialPasswordSetWithoutStepUp, exactly as before. + +user: + security: + stepUp: + enabled: true + ttlSeconds: 120 # how recently the WEBAUTHN factor must have been issued + factors: [WEBAUTHN] # any one is sufficient; WEBAUTHN is the only one whose refresh proves presence diff --git a/src/main/resources/static/js/user/step-up.js b/src/main/resources/static/js/user/step-up.js new file mode 100644 index 0000000..cdd70f5 --- /dev/null +++ b/src/main/resources/static/js/user/step-up.js @@ -0,0 +1,175 @@ +/** + * Step-up re-authentication for sensitive operations (SUF-02). + * + * When the server runs with `user.security.stepUp.enabled=true`, credential-altering operations on a + * passkey-only account require a recent WebAuthn assertion. Without one the server replies HTTP 401 in + * one of two shapes: + * + * - POST /user/setPassword ............ framework JSONResponse, `{ success: false, code: 6 }` + * - passkey delete / rename ........... GenericResponse, `{ error: "step-up-required" }` + * + * The remedy is the same for both: re-run the ordinary passkey login ceremony while still logged in, + * which refreshes the session's `FACTOR_WEBAUTHN` freshness, then retry the original request once. + * There is no dedicated step-up endpoint or token; the ceremony is the login flow the app already has. + * + * Re-running `/login/webauthn` triggers Spring Security's authentication-success handling: the session + * id changes (fixation protection) and, with the default session-based repository, the CSRF token + * rotates. The browser follows the new session cookie automatically, but the page still holds the old + * CSRF token in its meta tags, so this module refreshes them from `/csrf` before retrying. (The normal + * login path avoids this by navigating to a fresh page; step-up deliberately stays put.) + */ +import { authenticateWithPasskey } from '/js/user/webauthn-authenticate.js'; +import { getCsrfToken, getCsrfHeaderName } from '/js/user/webauthn-utils.js'; + +/** Raised when the user dismisses the step-up prompt instead of verifying. */ +export class StepUpCancelledError extends Error { + constructor() { + super('Step-up verification was cancelled.'); + this.name = 'StepUpCancelledError'; + } +} + +const DEFAULT_MESSAGE = "This is a sensitive change. Confirm with your passkey to continue."; + +/** + * Run a request that may require step-up. `requestFn` is a thunk returning a `fetch` Promise; it MUST + * read its CSRF header/token live (e.g. via getCsrfToken/getCsrfHeaderName) rather than closing over a + * captured value, because the retry runs after the token has been refreshed. + * + * If the server asks for step-up, the user is prompted, the passkey ceremony runs, and the request is + * retried once. The final Response is returned so the caller handles success and error exactly as it + * would without step-up. + * + * @param {() => Promise} requestFn the original request, callable more than once + * @param {{ message?: string }} [opts] optional prompt copy + * @returns {Promise} the final response (first response if no step-up was needed) + * @throws {StepUpCancelledError} if the user dismisses the prompt + * @throws {Error} if the passkey ceremony itself fails (e.g. the authenticator dialog is cancelled) + */ +export async function withStepUp(requestFn, { message } = {}) { + const response = await requestFn(); + if (!(await isStepUpRequired(response))) { + return response; + } + + await confirmStepUp(message || DEFAULT_MESSAGE); + await authenticateWithPasskey(); // refreshes FACTOR_WEBAUTHN; its redirectUrl is intentionally ignored + await refreshCsrfToken(); + return requestFn(); +} + +/** + * Detect the step-up-required 401 from either endpoint family. Reads a clone so the caller's response + * body stays unconsumed. + */ +async function isStepUpRequired(response) { + if (response.status !== 401) { + return false; + } + try { + const data = await response.clone().json(); + // code 6: setPassword step-up (JSONResponse). error "step-up-required": passkey delete/rename. + return data.code === 6 || data.error === 'step-up-required'; + } catch { + return false; + } +} + +/** + * Pull the current CSRF token from the server and update the page's meta tags, so the retry (and any + * later request reading getCsrfToken/getCsrfHeaderName) uses the token issued after re-authentication. + * Best-effort: a failure here just means the retry may surface a CSRF error, which the caller reports. + */ +async function refreshCsrfToken() { + try { + const response = await fetch('/csrf', { headers: { 'Accept': 'application/json' } }); + if (!response.ok) { + return; + } + const data = await response.json(); + setMeta('_csrf', data.token); + setMeta('_csrf_header', data.headerName); + } catch (error) { + console.warn('Failed to refresh CSRF token after step-up:', error); + } +} + +function setMeta(name, content) { + if (content == null) { + return; + } + let meta = document.querySelector(`meta[name="${name}"]`); + if (!meta) { + meta = document.createElement('meta'); + meta.setAttribute('name', name); + document.head.appendChild(meta); + } + meta.setAttribute('content', content); +} + +// --- Step-up prompt modal ------------------------------------------------------------------------- + +let modalInstance; + +const MODAL_HTML = ` +`; + +function ensureModal() { + let el = document.getElementById('stepUpModal'); + if (!el) { + const template = document.createElement('template'); + template.innerHTML = MODAL_HTML.trim(); + el = template.content.firstElementChild; + document.body.appendChild(el); + } + if (!modalInstance) { + modalInstance = new bootstrap.Modal(el); + } + return el; +} + +/** + * Show the step-up modal and resolve when the user clicks Verify, or reject with StepUpCancelledError + * when they dismiss it (Cancel button, close icon, backdrop click, or Escape). + */ +function confirmStepUp(message) { + return new Promise((resolve, reject) => { + const el = ensureModal(); + el.querySelector('#stepUpModalMessage').textContent = message; + const verifyBtn = el.querySelector('#stepUpVerifyBtn'); + + let verified = false; + const onVerify = () => { + verified = true; + modalInstance.hide(); + }; + const onHidden = () => { + verifyBtn.removeEventListener('click', onVerify); + el.removeEventListener('hidden.bs.modal', onHidden); + if (verified) { + resolve(); + } else { + reject(new StepUpCancelledError()); + } + }; + verifyBtn.addEventListener('click', onVerify); + el.addEventListener('hidden.bs.modal', onHidden); + modalInstance.show(); + }); +} diff --git a/src/main/resources/static/js/user/update-password.js b/src/main/resources/static/js/user/update-password.js index 83d6122..93c96b4 100644 --- a/src/main/resources/static/js/user/update-password.js +++ b/src/main/resources/static/js/user/update-password.js @@ -5,6 +5,8 @@ import { initPasswordRequirements, } from "/js/utils/password-validation.js"; import { getAuthMethods } from "/js/user/auth-methods.js"; +import { withStepUp, StepUpCancelledError } from "/js/user/step-up.js"; +import { getCsrfToken, getCsrfHeaderName } from "/js/user/webauthn-utils.js"; let isSetPasswordMode = false; @@ -72,17 +74,24 @@ document.addEventListener("DOMContentLoaded", async () => { confirmPassword: confirmPassword, }; - try { - const response = await fetch("/user/setPassword", { + // Setting an initial password on a passkey-only account is credential-altering, so with step-up + // enabled the server returns 401 (code 6) until a recent passkey assertion exists. withStepUp runs + // the passkey ceremony and retries; on servers with step-up off the first response is returned as-is. + const setPassword = () => + fetch("/user/setPassword", { method: "POST", headers: { "Content-Type": "application/json", - [document.querySelector("meta[name='_csrf_header']").content]: - document.querySelector("meta[name='_csrf']").content, + [getCsrfHeaderName()]: getCsrfToken(), }, body: JSON.stringify(requestData), }); + try { + const response = await withStepUp(setPassword, { + message: "Setting a password is a sensitive change. Confirm with your passkey to continue.", + }); + const data = await response.json(); if (response.ok && data.success) { @@ -93,8 +102,12 @@ document.addEventListener("DOMContentLoaded", async () => { showMessage(globalMessage, errorMessage, "alert-danger"); } } catch (error) { - console.error("Request failed:", error); - showMessage(globalMessage, "An unexpected error occurred. Please try again later.", "alert-danger"); + if (error instanceof StepUpCancelledError) { + showMessage(globalMessage, "Password not set — passkey verification was cancelled.", "alert-warning"); + } else { + console.error("Request failed:", error); + showMessage(globalMessage, "An unexpected error occurred. Please try again later.", "alert-danger"); + } } return; } diff --git a/src/main/resources/static/js/user/webauthn-manage.js b/src/main/resources/static/js/user/webauthn-manage.js index cd98af2..846f7ed 100644 --- a/src/main/resources/static/js/user/webauthn-manage.js +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -5,6 +5,7 @@ import { getCsrfToken, getCsrfHeaderName, isWebAuthnSupported, escapeHtml } from import { registerPasskey } from '/js/user/webauthn-register.js'; import { showMessage } from '/js/shared.js'; import { getAuthMethods, invalidateAuthMethodsCache } from '/js/user/auth-methods.js'; +import { withStepUp, StepUpCancelledError } from '/js/user/step-up.js'; const csrfHeader = getCsrfHeaderName(); const csrfToken = getCsrfToken(); @@ -144,16 +145,24 @@ function renamePasskey(credentialId, currentLabel) { const globalMessage = document.getElementById('passkeyMessage'); - try { - const response = await fetch(`/user/webauthn/credentials/${credentialId}/label`, { + // Renaming a passkey is credential-altering: with step-up enabled the server returns 401 + // (error "step-up-required") until a recent passkey assertion exists. Read the CSRF token live + // so the post-ceremony retry uses the token issued after re-authentication. + const renameRequest = () => + fetch(`/user/webauthn/credentials/${credentialId}/label`, { method: 'PUT', headers: { 'Content-Type': 'application/json', - [csrfHeader]: csrfToken + [getCsrfHeaderName()]: getCsrfToken() }, body: JSON.stringify({ label: newLabel }) }); + try { + const response = await withStepUp(renameRequest, { + message: 'Renaming a passkey is a sensitive change. Confirm with your passkey to continue.' + }); + if (!response.ok) { let msg = 'Failed to rename passkey'; try { @@ -174,10 +183,15 @@ function renamePasskey(credentialId, currentLabel) { loadPasskeys(); updateAuthMethodsUI(); } catch (error) { - console.error('Failed to rename passkey:', error); - errorEl.textContent = error.message; - errorEl.classList.remove('d-none'); - input.classList.add('is-invalid'); + if (error instanceof StepUpCancelledError) { + errorEl.textContent = 'Passkey not renamed — verification was cancelled.'; + errorEl.classList.remove('d-none'); + } else { + console.error('Failed to rename passkey:', error); + errorEl.textContent = error.message; + errorEl.classList.remove('d-none'); + input.classList.add('is-invalid'); + } } finally { confirmBtn.disabled = false; confirmBtn.textContent = 'Save'; @@ -203,10 +217,18 @@ async function deletePasskey(credentialId) { const globalMessage = document.getElementById('passkeyMessage'); - try { - const response = await fetch(`/user/webauthn/credentials/${credentialId}`, { + // Deleting a passkey is credential-altering: with step-up enabled the server returns 401 + // (error "step-up-required") until a recent passkey assertion exists. Read the CSRF token live so + // the post-ceremony retry uses the token issued after re-authentication. + const deleteRequest = () => + fetch(`/user/webauthn/credentials/${credentialId}`, { method: 'DELETE', - headers: { [csrfHeader]: csrfToken } + headers: { [getCsrfHeaderName()]: getCsrfToken() } + }); + + try { + const response = await withStepUp(deleteRequest, { + message: 'Deleting a passkey is a sensitive change. Confirm with your passkey to continue.' }); if (!response.ok) { @@ -228,9 +250,15 @@ async function deletePasskey(credentialId) { loadPasskeys(); updateAuthMethodsUI(); } catch (error) { - console.error('Failed to delete passkey:', error); - if (globalMessage) { - showMessage(globalMessage, error.message || 'Failed to delete passkey. Please try again.', 'alert-danger'); + if (error instanceof StepUpCancelledError) { + if (globalMessage) { + showMessage(globalMessage, 'Passkey not deleted — verification was cancelled.', 'alert-warning'); + } + } else { + console.error('Failed to delete passkey:', error); + if (globalMessage) { + showMessage(globalMessage, error.message || 'Failed to delete passkey. Please try again.', 'alert-danger'); + } } } } From 2936af0db73671956a691f0a9fe652b55c150a18 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 20 Aug 2026 21:11:31 -0600 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20apply=20step-up=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20enrollment=20403,=20CSRF,=20mail=20template,=20E?= =?UTF-8?q?2E=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses valid findings from the ticket-grounded review of #75, and closes gaps found by actually exercising the expanded acceptance criteria in a browser. Client: - Enrollment step-up (SUF-02) now has its own handling. POST /webauthn/register is gated by an authorization rule that returns a bare 403, not a step-up-required 401, and no passkey ceremony can satisfy a first-passkey enrollment. registerPasskey throws PasskeyEnrollmentStepUpError on 403 and the profile page shows an actionable "sign in again" message instead of the generic failure. - webauthn-manage.js now reads the CSRF header/token live in the remove-password and credential-list requests too, not just delete/rename. An in-page step-up ceremony rotates the CSRF token, so the previously captured module-level values were stale and the next state-changing request would 403 until reload. Demo: - Add the mail/webauthn-credential-registered.html template. 5.3.3 (#367) notifies the owner on every passkey registration via this template; the demo shipped without it, so enrollment logged a TemplateInputException. E2E (chromium-step-up): - Serial execution: concurrent passwordless registrations deadlock on the user_account insert (a framework-side concurrency limit), which surfaced as intermittent 500s. - Delete now has a ceremony-then-success case (deleting the last passkey on a passwordless account is blocked for lockout safety, so it enrolls a second passkey on a roaming authenticator first). - The setPassword case now asserts the browser-only behavior the ticket exists to verify: the page did not navigate, the session id is preserved (factor merging reuses the session rather than running fixation; the principal is unchanged so no fixation vector), the CSRF token rotated (which is why the /csrf refresh is load-bearing), and authorities survive (a protected page stays reachable). - Enrollment shortly after login is asserted explicitly. Verified: chromium-step-up 4/4; chromium-mfa 1/1; change-password + auth-methods + passwordless-registration 24/24 on a non-step-up server. Still outstanding from the expanded AC, deferred deliberately (see PR notes): TTL-expiry and stale-session-enrollment E2E (need a short-TTL profile or an aged session), social-login (OIDC) setPassword fallback (needs the Keycloak stack), the notification email/audit assertions (need Mailpit querying), and first-passkey-from-verification-link. Claude-Session: https://claude.ai/code/session_016iMnES4LsPse9LvJvyRpdN --- playwright/tests/step-up/step-up-flow.spec.ts | 93 ++++++++++++++++++- .../static/js/user/webauthn-manage.js | 18 ++-- .../static/js/user/webauthn-register.js | 21 +++++ .../mail/webauthn-credential-registered.html | 22 +++++ 4 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 src/main/resources/templates/mail/webauthn-credential-registered.html diff --git a/playwright/tests/step-up/step-up-flow.spec.ts b/playwright/tests/step-up/step-up-flow.spec.ts index 9d7ed21..1629c35 100644 --- a/playwright/tests/step-up/step-up-flow.spec.ts +++ b/playwright/tests/step-up/step-up-flow.spec.ts @@ -1,5 +1,5 @@ import { test, expect, generateTestUser, TestUser } from '../../src/fixtures'; -import type { Page } from '@playwright/test'; +import type { CDPSession, Page } from '@playwright/test'; /** * WebAuthn step-up (SUF-02) E2E, using Chromium's CDP virtual authenticator. @@ -20,13 +20,25 @@ import type { Page } from '@playwright/test'; * Enable a CDP WebAuthn virtual authenticator that auto-approves create()/get() so no human touch is * needed. Mirrors playwright/tests/mfa/mfa-flow.spec.ts. */ -async function setupVirtualAuthenticator(page: Page): Promise { +async function setupVirtualAuthenticator(page: Page): Promise { const cdp = await page.context().newCDPSession(page); await cdp.send('WebAuthn.enable'); + await addVirtualAuthenticator(cdp); + return cdp; +} + +/** + * Add one more virtual authenticator to an enabled CDP session. A second authenticator is needed to enroll a + * second passkey, because `excludeCredentials` makes the authenticator that already holds a credential decline + * a repeat enrollment. + */ +async function addVirtualAuthenticator(cdp: CDPSession, transport: 'internal' | 'usb' = 'internal'): Promise { + // Chrome allows only one 'internal' (platform) authenticator per environment, so a second credential must + // come from a roaming ('usb') authenticator. await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', - transport: 'internal', + transport, hasResidentKey: true, hasUserVerification: true, isUserVerified: true, @@ -67,6 +79,13 @@ async function createPasswordlessUserWithPasskey(page: Page, user: TestUser): Pr }); } +/** Return the servlet session cookie value, used to prove the id rotates across step-up (fixation). */ +async function getSessionCookie(page: Page): Promise { + const cookies = await page.context().cookies(); + const session = cookies.find((c) => c.name === 'JSESSIONID') || cookies.find((c) => /session/i.test(c.name)); + return session?.value; +} + /** Read the current credential id list via the management API. */ async function getCredentialIds(page: Page): Promise { return page.evaluate(async () => { @@ -79,6 +98,12 @@ async function getCredentialIds(page: Page): Promise { } test.describe('WebAuthn Step-Up @step-up-enabled', () => { + // Run serially: each test creates a passwordless account, and concurrent inserts into user_account + // deadlock in MariaDB under the framework's registration path. Serial execution keeps these + // account-creating flows deterministic (the same reason auth-flow specs avoid racing registration). + test.describe.configure({ mode: 'serial' }); + + test('setting a password on a passkey-only account requires step-up, then succeeds after the ceremony', async ({ page, cleanupEmails, @@ -95,6 +120,9 @@ test.describe('WebAuthn Step-Up @step-up-enabled', () => { await page.locator('#newPassword').fill(user.password); await page.locator('#confirmPassword').fill(user.password); + + const sessionBefore = await getSessionCookie(page); + const csrfBefore = await page.evaluate(() => document.querySelector('meta[name="_csrf"]')?.getAttribute('content')); await page.locator('#updatePasswordForm button[type="submit"]').click(); // The server refuses with 401 (code 6); the client shows the step-up modal rather than a raw error. @@ -112,6 +140,58 @@ test.describe('WebAuthn Step-Up @step-up-enabled', () => { return response.json(); }); expect(auth.data.hasPassword).toBe(true); + + // Browser-only verification the ticket exists to observe (step-up re-runs /login/webauthn mid-flow): + // - the login JSON landed mid-flow without navigating the page away, and the retry fired; + await expect(page).toHaveURL(/\/user\/update-password\.html/); + // - factor merging reuses the existing session rather than starting a fresh one, so the session id is + // preserved and the user stays logged in (no fixation rotation: the principal is unchanged, so no + // fixation vector is introduced); + const sessionAfter = await getSessionCookie(page); + expect(sessionAfter).toBeTruthy(); + expect(sessionAfter).toBe(sessionBefore); + // - but Spring still rotates the CSRF token on the re-authentication; the client picked up the new one, + // which is exactly what the /csrf refresh handles and why the retry did not fail with a 403; + const csrfAfter = await page.evaluate(() => document.querySelector('meta[name="_csrf"]')?.getAttribute('content')); + expect(csrfAfter).toBeTruthy(); + expect(csrfAfter).not.toBe(csrfBefore); + // - authorities survived the merge: a protected page is still reachable without re-login. + await page.goto('/user/update-user.html'); + await expect(page).toHaveURL(/\/user\/update-user\.html/); + }); + + test('deleting a passkey on a passkey-only account requires step-up, then succeeds after the ceremony', async ({ + page, + cleanupEmails, + }) => { + const user = generateTestUser('stepup-delete'); + cleanupEmails.push(user.email); + + const cdp = await setupVirtualAuthenticator(page); + await createPasswordlessUserWithPasskey(page, user); + + // Deleting the last passkey on a passwordless account is blocked (lockout protection), so enroll a second + // one first. It needs its own (roaming) authenticator, since the first one declines a repeat enrollment. + await addVirtualAuthenticator(cdp, 'usb'); + await page.goto('/user/update-user.html'); + await page.evaluate(async () => { + const { registerPasskey } = await import('/js/user/webauthn-register.js'); + await registerPasskey('second-passkey'); + }); + await page.reload(); + await page.locator('#passkeys-list button[data-action="delete"]').first().waitFor(); + expect((await getCredentialIds(page)).length).toBe(2); + + // The delete flow opens a native confirm() first; auto-accept it, then step up. + page.on('dialog', (dialog) => dialog.accept()); + await page.locator('#passkeys-list button[data-action="delete"]').first().click(); + + const verifyBtn = page.locator('#stepUpVerifyBtn'); + await expect(verifyBtn).toBeVisible(); + await verifyBtn.click(); + + await expect(page.locator('#passkeyMessage')).toHaveClass(/alert-success/, { timeout: 15000 }); + await expect.poll(async () => (await getCredentialIds(page)).length).toBe(1); }); test('renaming a passkey on a passkey-only account requires step-up, then succeeds after the ceremony', async ({ @@ -151,8 +231,11 @@ test.describe('WebAuthn Step-Up @step-up-enabled', () => { await createPasswordlessUserWithPasskey(page, user); await page.goto('/user/update-user.html'); - const [credentialId] = await getCredentialIds(page); - expect(credentialId).toBeTruthy(); + // Enrollment shortly after a real login succeeds: createPasswordlessUserWithPasskey enrolled a passkey + // on the fresh post-registration session (FACTOR_PASSWORD within enrollmentTtlSeconds), so one exists. + const credentialIds = await getCredentialIds(page); + expect(credentialIds.length).toBe(1); + const [credentialId] = credentialIds; // Raw calls with no ceremony: the server gate alone must produce the two documented 401 shapes. const results = await page.evaluate(async (credId) => { diff --git a/src/main/resources/static/js/user/webauthn-manage.js b/src/main/resources/static/js/user/webauthn-manage.js index 846f7ed..4595b9b 100644 --- a/src/main/resources/static/js/user/webauthn-manage.js +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -2,13 +2,14 @@ * WebAuthn credential management (list, rename, delete) for the user profile page. */ import { getCsrfToken, getCsrfHeaderName, isWebAuthnSupported, escapeHtml } from '/js/user/webauthn-utils.js'; -import { registerPasskey } from '/js/user/webauthn-register.js'; +import { registerPasskey, PasskeyEnrollmentStepUpError } from '/js/user/webauthn-register.js'; import { showMessage } from '/js/shared.js'; import { getAuthMethods, invalidateAuthMethodsCache } from '/js/user/auth-methods.js'; import { withStepUp, StepUpCancelledError } from '/js/user/step-up.js'; -const csrfHeader = getCsrfHeaderName(); -const csrfToken = getCsrfToken(); +// CSRF header/token are read live (getCsrfHeaderName/getCsrfToken) at each request rather than captured +// once: an in-page step-up ceremony re-runs /login/webauthn, which rotates the session's CSRF token, and a +// stale captured value would fail the next state-changing request until the page was reloaded. let renameModalInstance; let removePasswordModalInstance; @@ -22,7 +23,7 @@ export async function loadPasskeys() { try { const response = await fetch('/user/webauthn/credentials', { - headers: { [csrfHeader]: csrfToken } + headers: { [getCsrfHeaderName()]: getCsrfToken() } }); if (!response.ok) { @@ -283,7 +284,12 @@ async function handleRegisterPasskey() { } catch (error) { console.error('Registration error:', error); if (globalMessage) { - showMessage(globalMessage, 'Failed to register passkey. Please try again.', 'alert-danger'); + // A stale-session enrollment refusal (SUF-02) carries its own actionable message; other failures + // stay generic. + const message = error instanceof PasskeyEnrollmentStepUpError + ? error.message + : 'Failed to register passkey. Please try again.'; + showMessage(globalMessage, message, 'alert-danger'); } } } @@ -460,7 +466,7 @@ function initRemovePassword() { method: 'DELETE', headers: { 'Content-Type': 'application/json', - [csrfHeader]: csrfToken + [getCsrfHeaderName()]: getCsrfToken() } }); diff --git a/src/main/resources/static/js/user/webauthn-register.js b/src/main/resources/static/js/user/webauthn-register.js index 340697c..317a4c2 100644 --- a/src/main/resources/static/js/user/webauthn-register.js +++ b/src/main/resources/static/js/user/webauthn-register.js @@ -3,6 +3,21 @@ */ import { getCsrfToken, getCsrfHeaderName, base64urlToBuffer, bufferToBase64url } from '/js/user/webauthn-utils.js'; +/** + * Raised when passkey enrollment is refused because the session lacks a recent authentication (SUF-02). + * + * With step-up enabled the framework gates POST /webauthn/register on a factor issued within + * `enrollmentTtlSeconds`, enforced as an authorization rule that returns a plain 403 (not a + * `step-up-required` 401). Re-running the passkey ceremony cannot satisfy it: the user may have no passkey + * yet, and enrollment accepts any factor, so the remedy is a fresh login, not a ceremony retry. + */ +export class PasskeyEnrollmentStepUpError extends Error { + constructor() { + super('For your security, adding a passkey needs a recent sign-in. Please sign out and sign in again, then add the passkey.'); + this.name = 'PasskeyEnrollmentStepUpError'; + } +} + /** * Register a new passkey for the authenticated user. */ @@ -78,6 +93,12 @@ export async function registerPasskey(labelInput) { }); if (!finishResponse.ok) { + // The enrollment step-up gate is an authorization rule, so a stale/factorless session is refused here + // with a bare 403 rather than the step-up-required 401 the other operations return. Surface it as its + // own error so the UI can tell the user to sign in again instead of offering a passkey retry. + if (finishResponse.status === 403) { + throw new PasskeyEnrollmentStepUpError(); + } let msg = 'Registration failed'; try { const data = await finishResponse.json(); diff --git a/src/main/resources/templates/mail/webauthn-credential-registered.html b/src/main/resources/templates/mail/webauthn-credential-registered.html new file mode 100644 index 0000000..2a1d302 --- /dev/null +++ b/src/main/resources/templates/mail/webauthn-credential-registered.html @@ -0,0 +1,22 @@ + + + + + A passkey was added to your account + + + + +
+ User,
+

A new passkey was just added to your account: Passkey.

+
+

If this was you, no action is needed. If you did not add this passkey, someone else may have + access to your account: sign in, remove any passkey you do not recognize from your profile, and + set or change your password.

+
+
+

Thanks

+ + + From 5dcc23df60a905b63eedb41939caf2d6ce797d9c Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 21 Aug 2026 09:16:30 -0600 Subject: [PATCH 3/5] test: cover TTL-expiry, stale-session enrollment 403, and verification-link enrollment (#75) Adds the deterministic E2E acceptance cases from the expanded #75 criteria, via a test-only override profile (application-step-up-e2e.yml) layered under the chromium-step-up run: - ttlSeconds shrunk to 2 so a WEBAUTHN factor can be aged past the window in a few seconds. New test: assert a passkey login, wait it out, and setPassword is then refused 401 code 6 before any mutation (E2E AC "factor aged past ttlSeconds"). - dev.auto-login-enabled turned on so /dev/login-as reaches a factorless session deterministically. New test: enrollment from that session returns 403 and the UI shows the actionable "sign in again" message, no passkey added (E2E AC "stale session -> 403, UI explains"; exercises the new PasskeyEnrollmentStepUpError path). - New test: register-then-confirm via the emailed link auto-logs-in with FACTOR_OTT, and a first passkey enrolls successfully from that session (E2E AC "first-passkey enrollment from a verification-link session"). The realistic demo profile (application-step-up.yml, ttlSeconds 120) is unchanged; the short window lives only in the E2E override. Run: APP_PROFILES=local,playwright-test,step-up,step-up-e2e npx playwright test --project=chromium-step-up Verified: chromium-step-up 7/7. Claude-Session: https://claude.ai/code/session_016iMnES4LsPse9LvJvyRpdN --- playwright/tests/step-up/step-up-flow.spec.ts | 116 +++++++++++++++++- .../resources/application-step-up-e2e.yml | 18 +++ 2 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/application-step-up-e2e.yml diff --git a/playwright/tests/step-up/step-up-flow.spec.ts b/playwright/tests/step-up/step-up-flow.spec.ts index 1629c35..f1997ca 100644 --- a/playwright/tests/step-up/step-up-flow.spec.ts +++ b/playwright/tests/step-up/step-up-flow.spec.ts @@ -4,9 +4,10 @@ import type { CDPSession, Page } from '@playwright/test'; /** * WebAuthn step-up (SUF-02) E2E, using Chromium's CDP virtual authenticator. * - * Requires the app to run with step-up enabled: - * APP_PROFILES=local,playwright-test,step-up npx playwright test --project=chromium-step-up - * (the step-up profile must come last so its overrides win). + * Requires the app to run with step-up enabled and the E2E override profile: + * APP_PROFILES=local,playwright-test,step-up,step-up-e2e npx playwright test --project=chromium-step-up + * step-up-e2e shrinks ttlSeconds and enables dev login so the timing- and factor-dependent cases run + * deterministically; the later profiles must come last so their overrides win. * * Tagged @step-up-enabled so the default and MFA projects skip it: those servers run with step-up off. * @@ -275,4 +276,113 @@ test.describe('WebAuthn Step-Up @step-up-enabled', () => { expect(results.del.status).toBe(401); expect(results.del.body.error).toBe('step-up-required'); }); + + test('a WEBAUTHN factor aged past ttlSeconds no longer authorizes a sensitive operation', async ({ + page, + cleanupEmails, + }) => { + const user = generateTestUser('stepup-ttl'); + cleanupEmails.push(user.email); + + await setupVirtualAuthenticator(page); + await createPasswordlessUserWithPasskey(page, user); + + // Make the WEBAUTHN factor fresh by running the passkey ceremony (a login assertion while already + // logged in), then let it age past the step-up-e2e window (ttlSeconds=2). + await page.goto('/user/update-user.html'); + await page.evaluate(async () => { + const { authenticateWithPasskey } = await import('/js/user/webauthn-authenticate.js'); + await authenticateWithPasskey(); + }); + await page.waitForTimeout(3000); + // The assertion rotated the CSRF token; reload to pick up the current one (the factor's age is server + // state, unaffected by a page GET). + await page.reload(); + + const result = await page.evaluate(async () => { + const csrfHeader = document.querySelector('meta[name="_csrf_header"]')!.getAttribute('content')!; + const csrfToken = document.querySelector('meta[name="_csrf"]')!.getAttribute('content')!; + const response = await fetch('/user/setPassword', { + method: 'POST', + headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken }, + body: JSON.stringify({ newPassword: 'Test@Pass123!', confirmPassword: 'Test@Pass123!' }), + }); + return { status: response.status, body: await response.json() }; + }); + + // The aged factor is refused exactly like an absent one: 401 code 6, before any mutation. + expect(result.status).toBe(401); + expect(result.body.code).toBe(6); + const auth = await page.evaluate(async () => (await fetch('/user/auth-methods')).json()); + expect(auth.data.hasPassword).toBe(false); + }); + + test('enrollment from a factorless (stale) session is refused with 403 and an actionable message', async ({ + page, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('stepup-stale-enroll'); + cleanupEmails.push(user.email); + await testApiClient.createUser({ + email: user.email, + password: user.password, + firstName: user.firstName, + lastName: user.lastName, + enabled: true, + }); + + await setupVirtualAuthenticator(page); + + // Dev login stamps no authentication factor, so the session cannot satisfy the enrollment gate. This is + // the deterministic stand-in for a session aged past enrollmentTtlSeconds. + const devLogin = await page.request.get(`/dev/login-as/${encodeURIComponent(user.email)}`); + expect(devLogin.ok()).toBeTruthy(); + + await page.goto('/user/update-user.html'); + await page.locator('#registerPasskeyBtn').waitFor(); + await page.locator('#passkeyLabel').fill('should-be-refused'); + await page.locator('#registerPasskeyBtn').click(); + + // POST /webauthn/register returns a bare 403 (an authorization rule, not a step-up-required 401), which + // the client surfaces as its own "sign in again" message rather than offering a passkey retry. + await expect(page.locator('#passkeyMessage')).toHaveClass(/alert-danger/, { timeout: 15000 }); + await expect(page.locator('#passkeyMessage')).toContainText(/sign in again/i); + // No passkey was added. + expect((await getCredentialIds(page)).length).toBe(0); + }); + + test('first-passkey enrollment works when the only session came from the verification link', async ({ + page, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('stepup-verify-enroll'); + cleanupEmails.push(user.email); + // A registered-but-unverified account; confirming the emailed link both enables it and logs it in. + await testApiClient.createUser({ + email: user.email, + password: user.password, + firstName: user.firstName, + lastName: user.lastName, + enabled: false, + }); + await testApiClient.createVerificationToken(user.email); + + await setupVirtualAuthenticator(page); + + // Confirming the verification link auto-logs-in with FACTOR_OTT (not WEBAUTHN), which is a factor and so + // satisfies the enrollment gate: the user can register a first passkey right after verifying. + const verificationUrl = await testApiClient.getVerificationUrl(user.email); + await page.goto(verificationUrl!); + await expect(page).toHaveURL(/registration-complete/); + + await page.goto('/user/update-user.html'); + await page.evaluate(async () => { + const { registerPasskey } = await import('/js/user/webauthn-register.js'); + await registerPasskey('verify-link-passkey'); + }); + await page.reload(); + expect((await getCredentialIds(page)).length).toBe(1); + }); }); diff --git a/src/main/resources/application-step-up-e2e.yml b/src/main/resources/application-step-up-e2e.yml new file mode 100644 index 0000000..b4e428b --- /dev/null +++ b/src/main/resources/application-step-up-e2e.yml @@ -0,0 +1,18 @@ +# Step-Up E2E override profile (test-only) +# +# Layered on top of `step-up` for the chromium-step-up Playwright project only: +# APP_PROFILES=local,playwright-test,step-up,step-up-e2e npx playwright test --project=chromium-step-up +# +# It shrinks the freshness window and enables dev login so the timing- and factor-dependent acceptance +# cases can be exercised deterministically, without degrading the realistic demo values in +# application-step-up.yml (ttlSeconds 120). Never activate this profile outside E2E. +user: + security: + stepUp: + # A 2s freshness window lets the "factor aged past ttlSeconds" case wait it out in a few seconds. The + # ceremony-then-retry cases are unaffected: their retry fires within milliseconds of the assertion. + ttlSeconds: 2 + dev: + # Enables GET /dev/login-as/{email} (local profile only), which logs in without stamping any factor. + # Used to reach a factorless session deterministically for the "stale session -> enrollment 403" case. + auto-login-enabled: true From da000c48203220e36c23c71eee408d3095f44242 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 21 Aug 2026 09:34:01 -0600 Subject: [PATCH 4/5] test: assert passkey-registration notification email via Mailpit (#75) Adds the notification acceptance case (#367): registering a passkey emails the owner. Mail is redirected to a Mailpit catcher and the E2E asserts the message arrives. - compose.dev.yaml: add Mailpit (SMTP 1025, REST/web 8025). bootRun starts it automatically alongside MariaDB; nothing sends to it unless a profile points spring.mail there. - application-step-up-e2e.yml: point spring.mail at localhost:1025 so enrollment notifications (notifyOnRegistration on by default) land in Mailpit. - step-up-flow.spec.ts: new test polls Mailpit's REST API for the "New passkey added to your account" message to the enrolling user. - Docs: TESTING.md and CONFIGURATION.md document the step-up-e2e override and Mailpit. The notifyOnRegistration=false suppression and the PasskeyRegistration audit event are covered library-side (#367); the audit event is not observable from the browser harness. Social-login (OIDC) setPassword fallback is deferred to #90 (needs the Keycloak stack). Verified: chromium-step-up 8/8. Claude-Session: https://claude.ai/code/session_016iMnES4LsPse9LvJvyRpdN --- compose.dev.yaml | 19 +++++++++++++ docs/CONFIGURATION.md | 1 + docs/TESTING.md | 12 ++++++++- playwright/tests/step-up/step-up-flow.spec.ts | 27 +++++++++++++++++++ .../resources/application-step-up-e2e.yml | 14 ++++++++++ 5 files changed, 72 insertions(+), 1 deletion(-) diff --git a/compose.dev.yaml b/compose.dev.yaml index 75ac4d7..0455223 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -16,5 +16,24 @@ services: volumes: - mariadb-data:/var/lib/mysql + # Local mail catcher. bootRun starts it automatically (spring.docker.compose.file: compose.dev.yaml). + # Nothing sends to it unless a profile points spring.mail here: the base config uses real SMTP, and the + # step-up E2E profile (application-step-up-e2e.yml) redirects mail to localhost:1025 so the Playwright + # suite can assert the passkey-registration notification via the web API on 8025. + mailpit: + image: axllent/mailpit:v1.30.7 + ports: + - "1025:1025" # SMTP + - "8025:8025" # web UI + REST API + environment: + MP_MAX_MESSAGES: 5000 + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + healthcheck: + test: ["CMD", "/mailpit", "readyz"] + interval: 10s + timeout: 5s + retries: 5 + volumes: mariadb-data: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8c03b98..1c7bb83 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -33,6 +33,7 @@ If you omit `--args` entirely, `bootRun` still defaults to `local`: `build.gradl | `docker-keycloak` | [`application-docker-keycloak.yml`](../src/main/resources/application-docker-keycloak.yml) (tracked; holds only `${...}` placeholders, nothing to copy) | OIDC login against the bundled Keycloak stack; see [`keycloak/README.md`](../keycloak/README.md) and [AUTHENTICATION.md#keycloak](AUTHENTICATION.md#keycloak) for the full walkthrough | Adds the Keycloak OAuth2 client/provider from `DS_SPRING_USER_KEYCLOAK_*` env vars (deliberately no `issuer-uri`), insecure session cookie | `--spring.profiles.active=docker-keycloak`, normally set for you as `SPRING_PROFILES_ACTIVE` inside `docker-compose-keycloak.yml` | | `mfa` | [`application-mfa.yml`](../src/main/resources/application-mfa.yml) | Add-on: require PASSWORD + WEBAUTHN | `user.mfa.enabled: true` (base `application.yml:126` has it `false`); once enabled, the framework auto-unprotects the configured MFA entry-point URIs at runtime, including the challenge page, so a partially-authenticated user can reach them; the profile's yml additionally adds the passkey enrollment endpoints `/webauthn/register/options` and `/webauthn/register` to `unprotectedURIs` (line 25) so that user can register their first passkey; `allowInitialPasswordSetWithoutStepUp: true` | Combine with a base profile, e.g. `local,mfa` | | `step-up` | [`application-step-up.yml`](../src/main/resources/application-step-up.yml) | Add-on: require a recent passkey assertion for credential-altering operations on passkey-only accounts (SUF-02) | `user.security.stepUp.enabled: true` (base default is `false`), `ttlSeconds: 120`, `factors: [WEBAUTHN]`; this registers the framework's built-in `StepUpService`, so `POST /user/setPassword` and passkey delete/rename return `401` until a fresh `WEBAUTHN` factor exists, and passkey enrollment is gated on a recent authentication. See [AUTHENTICATION.md#webauthn-step-up-suf-02](AUTHENTICATION.md#webauthn-step-up-suf-02) | Combine with a base profile, e.g. `local,step-up` | +| `step-up-e2e` | [`application-step-up-e2e.yml`](../src/main/resources/application-step-up-e2e.yml) | **Test-only** override for the `chromium-step-up` Playwright run; never use outside E2E | Shrinks `stepUp.ttlSeconds` to `2` (deterministic factor aging), enables `user.dev.auto-login-enabled` (factorless session via `/dev/login-as`), and points `spring.mail` at the Mailpit catcher in `compose.dev.yaml` (notification assertion). Layer it last, after `step-up` | `local,playwright-test,step-up,step-up-e2e` (see [TESTING.md](TESTING.md)) | | `registration-guard` | none (no yml; `@Profile("registration-guard")` on [`DomainRegistrationGuard`](../src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java)) | Add-on: domain-restricted registration demo | Activates a `RegistrationGuard` bean that restricts form/passwordless registration to one email domain (`registration.guard.allowed-domain`, default `@example.com`); OAuth2/OIDC registration is unaffected | Combine with a base profile, e.g. `local,registration-guard` | See [AUTHENTICATION.md](AUTHENTICATION.md) for the mechanics behind `mfa` diff --git a/docs/TESTING.md b/docs/TESTING.md index 81be2ee..b4d7f2c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -109,13 +109,23 @@ authenticator, so they are Chromium-only. APP_PROFILES=local,playwright-test,mfa npx playwright test --project=chromium-mfa # WebAuthn step-up / SUF-02 (@step-up-enabled) -APP_PROFILES=local,playwright-test,step-up npx playwright test --project=chromium-step-up +APP_PROFILES=local,playwright-test,step-up,step-up-e2e npx playwright test --project=chromium-step-up ``` The `playwright-test` profile also pins `user.webauthn.rpId=localhost` and `allowedOrigins=http://localhost:8080`, so the virtual authenticator ceremonies work even when a developer's `application-local.yml` points WebAuthn at an ngrok host. +The step-up run adds `step-up-e2e` (`application-step-up-e2e.yml`), a test-only override that shrinks +`stepUp.ttlSeconds` to 2 (so a factor can be aged past the window in a few seconds), enables dev login +(`/dev/login-as`, for a deterministic factorless session), and redirects mail to the Mailpit catcher in +`compose.dev.yaml` (published on 1025/8025) so the suite can assert the passkey-registration notification. +`bootRun` starts Mailpit automatically alongside MariaDB. The realistic demo values stay in +`application-step-up.yml` (`ttlSeconds: 120`). The step-up specs run serially +(`test.describe.configure({ mode: 'serial' })`) because concurrent account registration deadlocks in +MariaDB (framework issue devondragon/SpringUserFramework#368). One acceptance case is not covered here: +social-login (OIDC) `setPassword` fallback, which needs the Keycloak stack (tracked separately). + **Test API**: [`TestDataController`](../src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java) exposes `/api/test/*` (create/enable/unlock/delete a user, fetch verification and password-reset diff --git a/playwright/tests/step-up/step-up-flow.spec.ts b/playwright/tests/step-up/step-up-flow.spec.ts index f1997ca..079b96c 100644 --- a/playwright/tests/step-up/step-up-flow.spec.ts +++ b/playwright/tests/step-up/step-up-flow.spec.ts @@ -385,4 +385,31 @@ test.describe('WebAuthn Step-Up @step-up-enabled', () => { await page.reload(); expect((await getCredentialIds(page)).length).toBe(1); }); + + test('registering a passkey notifies the account owner by email', async ({ page, cleanupEmails }) => { + const user = generateTestUser('stepup-notify'); + cleanupEmails.push(user.email); + + await setupVirtualAuthenticator(page); + // createPasswordlessUserWithPasskey enrolls a passkey, which (notifyOnRegistration is on by default) + // publishes a PasskeyRegistration audit event and emails the owner. Mail is redirected to Mailpit by the + // step-up-e2e profile; poll its REST API for the notification (delivery is asynchronous). + await createPasswordlessUserWithPasskey(page, user); + + await expect + .poll( + async () => { + const response = await page.request.get( + `http://localhost:8025/api/v1/search?query=${encodeURIComponent(`to:${user.email}`)}` + ); + if (!response.ok()) return 0; + const data = await response.json(); + return (data.messages ?? []).filter((m: { Subject?: string }) => + (m.Subject ?? '').includes('New passkey added') + ).length; + }, + { timeout: 10000 } + ) + .toBeGreaterThan(0); + }); }); diff --git a/src/main/resources/application-step-up-e2e.yml b/src/main/resources/application-step-up-e2e.yml index b4e428b..358c708 100644 --- a/src/main/resources/application-step-up-e2e.yml +++ b/src/main/resources/application-step-up-e2e.yml @@ -16,3 +16,17 @@ user: # Enables GET /dev/login-as/{email} (local profile only), which logs in without stamping any factor. # Used to reach a factorless session deterministically for the "stale session -> enrollment 403" case. auto-login-enabled: true + +# Redirect mail to the Mailpit catcher from compose.dev.yaml so the E2E can assert the passkey-registration +# notification (user.webauthn.notifyOnRegistration is on by default) via Mailpit's REST API on 8025. +spring: + mail: + host: localhost + port: 1025 + properties: + mail: + smtp: + auth: false + starttls: + enable: false + required: false From 7b4c784197f5adb10910ed36a3027966a583d2b7 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 21 Aug 2026 10:13:55 -0600 Subject: [PATCH 5/5] ci: run the chromium-step-up E2E project (#75) Adds a third Playwright E2E step covering the step-up suite, with a Mailpit service container for the notification assertion. Uses APP_PROFILES=local,playwright-test,step-up,step-up-e2e: local activates the dev-login controller (@Profile("local")) the factorless-session case needs, application-local.yml is absent in CI so it contributes no overrides, and playwright-test pins rpId=localhost for the virtual authenticator. Claude-Session: https://claude.ai/code/session_016iMnES4LsPse9LvJvyRpdN --- .github/workflows/tests.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7c1cd48..e748c30 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,6 +51,22 @@ jobs: --health-interval=10s --health-timeout=5s --health-retries=5 + mailpit: + # Mail catcher for the step-up run's passkey-registration notification assertion. Compose is + # disabled in CI (SPRING_DOCKER_COMPOSE_ENABLED=false), so the compose.dev.yaml Mailpit is + # replaced by this service container; the step-up-e2e profile points spring.mail at localhost:1025. + image: axllent/mailpit:v1.30.7 + env: + MP_SMTP_AUTH_ACCEPT_ANY: "1" + MP_SMTP_AUTH_ALLOW_INSECURE: "1" + ports: + - 1025:1025 + - 8025:8025 + options: >- + --health-cmd="/mailpit readyz" + --health-interval=10s + --health-timeout=5s + --health-retries=5 env: # The MariaDB service container replaces Spring Boot's Docker Compose integration SPRING_DOCKER_COMPOSE_ENABLED: "false" @@ -91,6 +107,14 @@ jobs: APP_PROFILES: playwright-test,mfa run: npx playwright test --project=chromium-mfa + - name: Run E2E tests (step-up enabled) + working-directory: playwright + # `local` activates the dev-login controller (@Profile("local")) the factorless-session case needs; + # application-local.yml is absent in CI so it adds no overrides, and playwright-test pins rpId=localhost. + env: + APP_PROFILES: local,playwright-test,step-up,step-up-e2e + run: npx playwright test --project=chromium-step-up + - name: Upload Playwright report if: failure() uses: actions/upload-artifact@v4