ERA-13405: RFC 9470 MFA step-up handler in the REST API client#1659
ERA-13405: RFC 9470 MFA step-up handler in the REST API client#1659StephenWithPH wants to merge 4 commits into
Conversation
Add isStepUpChallenge / parseAuthChallenge to utils/auth-recovery: detect an insufficient_user_authentication Bearer challenge and extract acr_values / max_age. Shared so both the REST interceptor (this ticket) and the socket handler (ERA-13733) parse the challenge identically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
recoverAuth tracks separate in-flight slots for silent vs step-up, so a step-up 401 can't collapse into an in-flight silent renewal — a coalesced silent token is same-ACR and never satisfies the MFA challenge, which would defeat step-up. Silent-mode behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erceptor
RequestConfigManager.handle401Errors reads the WWW-Authenticate header on a
401: an insufficient_user_authentication challenge routes to
recoverAuth({ stepUp: true, challenge }) for interactive re-MFA, while an
ordinary 401 keeps the silent-renew path. The step-up primitive itself is
registered separately (next commit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🚀 PR Environment Deployed
Access: https://era-13405.dev.pamdas.org |
There was a problem hiding this comment.
Pull request overview
Adds RFC 9470 “insufficient_user_authentication” (MFA step-up) handling to the web client’s REST auth recovery flow, allowing axios requests that receive an MFA step-up challenge to trigger an Auth0 interactive step-up rather than failing as a generic 401.
Changes:
- Added RFC 9470 Bearer challenge parsing (
parseAuthChallenge) and detection (isStepUpChallenge) to route MFA step-up challenges. - Updated auth-recovery single-flight behavior to key in-flight recovery by mode (
silentvsstepUp) so step-up cannot collapse into silent renewals. - Updated the axios 401 interceptor to detect step-up challenges and call
recoverAuth({ stepUp: true, challenge }), and registered astepUpprimitive vialoginWithRedirect.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/utils/auth-recovery.js | Adds RFC 9470 challenge parsing + step-up detection, and mode-keyed in-flight recovery. |
| src/utils/auth-recovery.test.js | Adds unit coverage for step-up parsing and mode-keyed single-flight behavior. |
| src/RequestConfigManager/index.js | Routes 401s with RFC 9470 step-up challenges to interactive step-up recovery. |
| src/RequestConfigManager/index.test.js | Adds tests for step-up routing, silent routing, and escalation behavior after replay. |
| src/hooks/useAuthRecovery.js | Registers the interactive step-up primitive using Auth0 loginWithRedirect with challenge params. |
| src/hooks/useAuthRecovery.test.js | Adds tests validating the registered silentRenew and stepUp primitives’ behavior. |
useAuthRecovery now registers a stepUp primitive: on a step-up challenge it stashes the current route and re-runs the Auth0 PKCE authorize with the challenge's acr_values/max_age via loginWithRedirect. The redirect callback (Auth0TokenManager) re-bootstraps the app with the MFA-bearing token and returns the user to where they were. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chrisj-er
left a comment
There was a problem hiding this comment.
Nothing blocking. Highlights:
- Step-up latch has no watchdog (minor). The stepUp primitive returns new Promise(() => {}) that never settles, so recoverAuth's .finally never releases inFlight.stepUp. Correct in the normal flow (page redirects and unloads), but if loginWithRedirect ever resolves without navigating, the latch stays stuck forever and every later step-up 401 hangs with no sign-out fallback. Low-probability, but it's a silent hang rather than a recoverable error.
|
@chrisj-er good catch on the step-up latch watchdog. It's addressed in the stacked follow-up PR #1663 (ERA-13733, based on this branch), commit 1df48b3: the step-up flight is now time-boxed by a 60s redirect-handoff watchdog, so if |
What & why
ERA-13405
Adds the RFC 9470 MFA step-up handler to ER Web's REST client, building on the shared
auth-recoveryunit from ERA-13685. A401 WWW-Authenticate: Bearer error="insufficient_user_authentication"now triggers a transparent Auth0 step-up (re-run PKCE with the challenge'sacr_values/max_age) instead of an opaque auth failure.Commits
isStepUpChallenge/parseAuthChallengeinutils/auth-recovery(shared, so the socket handler in ERA-13733 reuses them).inFlightis{ silent, stepUp }, so a step-up can't collapse into an in-flight silent renewal (a same-ACR silent token never satisfies the MFA challenge).RequestConfigManager.handle401ErrorsreadsWWW-Authenticate; a step-up challenge routes torecoverAuth({ stepUp: true, challenge }), an ordinary 401 keeps the silent-renew path.useAuthRecoveryregistersstepUp=loginWithRedirectwith the challenge'sacr_values/max_age, returnTo stashed; the redirect callback re-bootstraps with the MFA-bearing token.How it works (end to end)
Step-up 401 → interceptor parses
acr_values/max_age→recoverAuth({ stepUp })→loginWithRedirect→ user completes MFA → the redirect callback (Auth0TokenManager) re-bootstraps with the MFA token and returns the user to the original route.Screen.Recording.2026-07-17.at.10.54.29.mov
Dependencies / status
auth-recoveryunit) — merged.WWW-Authenticatestep-up on 401) is In Test — end-to-end exercise needs arequire_mfaenv, hence thedeploylabel.Behavior notes — worth discussing
loginWithRedirectis a full-page navigation, so a half-entered report / patrol / notes are lost. This is benign at the defaultmfa_max_age_seconds(365 days) — step-up is then effectively a login-time event, before any data entry. But a tenant that sets a shortmfa_max_agere-introduces mid-session, mid-form step-ups and real data loss. It's not a regression (new with MFA) and is strictly better than the opaque sign-out it replaces (which would also lose the form, then drop you at login). Escape hatch if it bites: the popup variant (getAccessTokenWithPopup, no reload) — the design is seam'd so only thestepUpprimitive changes.retriedAfterRefreshmarker is bypassed for step-up (stepUp || !retriedAfterRefresh), so a token that is expired and MFA-stale (silent-renews, then the replay 401s with a step-up challenge) escalates to step-up instead of signing out. Step-up redirects rather than looping, so bypassing the marker is safe.require_mfasite it treats the MFA rejection as a generic 401 → silent-renew → sign-out, racing the REST redirect (likely benign — the hard redirect wins — but messy). The mode-keyed single-flight is already set up so that once the socket recognizes step-up, both transports coalesce into a single redirect.require_mfais enabled; (A) specifically needs token-expiry and MFA-staleness to coincide (shortmfa_max_age; never at the 365-day default).Testing
error_descriptiondecoy), mode-keyed single-flight (both directions + concurrent step-up coalescing), interceptor silent/step-up routing + the escalation case, and thestepUpprimitive (reject-surfaces + never-settle).Deploy
deploylabel added to spin up arequire_mfa-capable feature env for manual end-to-end verification (the unit tests mock at the seams).🤖 Generated with Claude Code