From 5f7c3c027446c265a7b09fad0a0eb2e021f5ee04 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 21 Aug 2026 19:07:38 -0600 Subject: [PATCH] docs: note WebAuthn enrollment denial changed from 403 to 401+step-up-required in 5.3.4 #371 changed the stale/factorless-session denial on POST /webauthn/register from a bare HTTP 403 (the filter-chain authorization rule's default) to HTTP 401 with a JSON body carrying error code "step-up-required", matching the passkey delete/rename endpoints so a client can interpret it. That is a consumer-facing response-contract change with no migration note; a client that branched on the 403 for this endpoint (as the demo app's webauthn-register.js did) silently falls back to a generic error. Add a 5.3.3 -> 5.3.4 callout to the enrollment-gating section directing such clients to also treat a 401 with error code step-up-required as the stale-session case. Surfaced by the pre-release demo-app integration test (chromium-step-up E2E). Claude-Session: https://claude.ai/code/session_01KL5qvKVGQLLQDjHToy34vj --- MIGRATION.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MIGRATION.md b/MIGRATION.md index af2ef21..cb77406 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -227,6 +227,8 @@ Affected endpoints (all require `user.webauthn.enabled=true` except where noted) **Passkey enrollment is gated when step-up is enabled.** `POST /webauthn/register` now requires an authentication factor issued within `user.security.stepUp.enrollmentTtlSeconds` (default `600`). Without it the feature protected nothing: an attacker holding a session cookie could enroll their own passkey and assert with it to satisfy every other gate. Any factor counts, not the configured `factors` list, so a first passkey can still be registered after an ordinary password or social login. +> **5.3.3 → 5.3.4 note — enrollment denial response.** Through 5.3.3 the stale/factorless-session denial on `POST /webauthn/register` was a bare `HTTP 403` (the filter-chain authorization rule's default, with no interpretable body). As of 5.3.4 it is `HTTP 401` with a JSON body `{"message": ..., "error": "step-up-required"}`, matching the passkey delete/rename endpoints so a client can recognize it and prompt for a fresh sign-in instead of a passkey retry. If you wrote a client that branched on the `403` for this endpoint, update it to also treat a `401` with error code `step-up-required` as the stale-session case. Re-running the passkey ceremony cannot satisfy enrollment (it accepts any factor and the account may have no passkey yet), so the remedy is a fresh login. + To make that possible, login flows that previously left no factor now stamp one: OIDC logins (`OidcAuthorizationCodeAuthenticationProvider` stamps none, unlike the password and plain-OAuth2 providers), the email-verification link (`FACTOR_OTT`), and post-registration auto-login (`FACTOR_PASSWORD`). Dev login still stamps nothing, so passkey enrollment is unavailable under `user.dev.auto-login-enabled` while step-up is on. `UserService.authWithoutPassword(User)` is unchanged for existing callers; a new overload takes the factor. **`StepUpService` gained a default method.** `canSatisfyStepUp(User)` reports whether a user could satisfy step-up at all, as opposed to whether they have. It defaults to `true`, so existing implementations compile and behave exactly as before. Override it when your mechanism depends on a credential some accounts lack: an OAuth2/OIDC account with no passkey can never produce a WebAuthn factor, and callers treat `false` as "step-up does not apply" and fall back to their configured default rather than rejecting an operation the user could never unlock. The built-in service overrides it, so with `user.security.stepUp.enabled=true` a social-login account with no passkey keeps the `allowInitialPasswordSetWithoutStepUp` behavior instead of receiving a permanent `HTTP 401` on `POST /user/setPassword`. The same applies to passkey delete and rename, which fall back to their pre-feature behavior for accounts that cannot satisfy the configured factors.