From 284bf06fca51418f9acc3fc1776bd0d55090f1e7 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 21 Aug 2026 19:08:26 -0600 Subject: [PATCH] fix: recognize 401 step-up-required on passkey enrollment denial (SUF-02) The framework's stale/factorless-session denial on POST /webauthn/register changed in ds-spring-user-framework 5.3.4 from a bare 403 to a 401 carrying error code "step-up-required" (StepUpEnrollmentAccessDeniedHandler, library #371), matching the passkey delete/rename endpoints. webauthn-register.js only branched on 403, so against 5.3.4 a stale-session enrollment fell through to the generic "Failed to register passkey. Please try again." instead of the actionable "sign out and sign in again" message, and the chromium-step-up E2E (step-up-flow.spec.ts:320) failed. Recognize both: 403 (framework < 5.3.4) or 401 with error code "step-up-required" (5.3.4+) now raise PasskeyEnrollmentStepUpError. Backward-compatible, so the test passes whether the demo is pinned to 5.3.3 or bumped to 5.3.4. Verified locally against a 5.3.4-SNAPSHOT publishLocal build: chromium-step-up 8/8. Claude-Session: https://claude.ai/code/session_01KL5qvKVGQLLQDjHToy34vj --- .../static/js/user/webauthn-register.js | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/resources/static/js/user/webauthn-register.js b/src/main/resources/static/js/user/webauthn-register.js index 317a4c2..16a9028 100644 --- a/src/main/resources/static/js/user/webauthn-register.js +++ b/src/main/resources/static/js/user/webauthn-register.js @@ -7,9 +7,11 @@ import { getCsrfToken, getCsrfHeaderName, base64urlToBuffer, bufferToBase64url } * 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. + * `enrollmentTtlSeconds`. Framework versions before 5.3.4 denied with a plain 403; 5.3.4+ denies with a + * 401 carrying error code `step-up-required` (StepUpEnrollmentAccessDeniedHandler), matching the sibling + * credential-management endpoints. Re-running the passkey ceremony cannot satisfy it either way: 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() { @@ -93,20 +95,23 @@ 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(); - } + // A stale/factorless session is refused by the enrollment step-up gate. Framework versions before + // 5.3.4 denied with a bare 403 (an authorization rule with no body); 5.3.4+ denies with a 401 + // carrying error code "step-up-required". Recognize both, and surface it as its own error so the UI + // can tell the user to sign in again instead of offering a passkey retry. let msg = 'Registration failed'; + let errorCode; try { const data = await finishResponse.json(); msg = data.message || msg; + errorCode = data.error; } catch { const text = await finishResponse.text(); if (text) msg = text; } + if (finishResponse.status === 403 || (finishResponse.status === 401 && errorCode === 'step-up-required')) { + throw new PasskeyEnrollmentStepUpError(); + } throw new Error(msg); }