Skip to content

ERA-13405: RFC 9470 MFA step-up handler in the REST API client#1659

Open
StephenWithPH wants to merge 4 commits into
developfrom
ERA-13405
Open

ERA-13405: RFC 9470 MFA step-up handler in the REST API client#1659
StephenWithPH wants to merge 4 commits into
developfrom
ERA-13405

Conversation

@StephenWithPH

@StephenWithPH StephenWithPH commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

What & why

ERA-13405

Adds the RFC 9470 MFA step-up handler to ER Web's REST client, building on the shared auth-recovery unit from ERA-13685. A 401 WWW-Authenticate: Bearer error="insufficient_user_authentication" now triggers a transparent Auth0 step-up (re-run PKCE with the challenge's acr_values/max_age) instead of an opaque auth failure.

Commits

  1. parse RFC 9470 step-up challengesisStepUpChallenge / parseAuthChallenge in utils/auth-recovery (shared, so the socket handler in ERA-13733 reuses them).
  2. key auth-recovery single-flight by modeinFlight is { 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).
  3. route step-up 401s in the interceptorRequestConfigManager.handle401Errors reads WWW-Authenticate; a step-up challenge routes to recoverAuth({ stepUp: true, challenge }), an ordinary 401 keeps the silent-renew path.
  4. register the interactive step-up primitiveuseAuthRecovery registers stepUp = loginWithRedirect with the challenge's acr_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_agerecoverAuth({ 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

  • Builds on ERA-13685 (shared auth-recovery unit) — merged.
  • Server emission ERA-13387 (WWW-Authenticate step-up on 401) is In Test — end-to-end exercise needs a require_mfa env, hence the deploy label.
  • Socket step-up (ERA-13733) is deferred, blocked on server ERA-13732; this PR is REST-only.

Behavior notes — worth discussing

  • The step-up redirect discards unsaved in-flight state. loginWithRedirect is a full-page navigation, so a half-entered report / patrol / notes are lost. This is benign at the default mfa_max_age_seconds (365 days) — step-up is then effectively a login-time event, before any data entry. But a tenant that sets a short mfa_max_age re-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 the stepUp primitive changes.
  • Silent ↔ step-up interaction.
    • (A) handled here: the per-request retriedAfterRefresh marker 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.
    • (B) deferred to ERA-13732/13733: until the socket can see the step-up challenge, on a require_mfa site 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.
    • Both only bite once require_mfa is enabled; (A) specifically needs token-expiry and MFA-staleness to coincide (short mfa_max_age; never at the 365-day default).

Testing

  • Unit: challenge parsing (incl. an error_description decoy), mode-keyed single-flight (both directions + concurrent step-up coalescing), interceptor silent/step-up routing + the escalation case, and the stepUp primitive (reject-surfaces + never-settle).
  • Full suite green.

Deploy

deploy label added to spin up a require_mfa-capable feature env for manual end-to-end verification (the unit tests mock at the seams).

🤖 Generated with Claude Code

StephenWithPH and others added 3 commits July 16, 2026 15:57
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>
@StephenWithPH StephenWithPH self-assigned this Jul 16, 2026
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

🚀 PR Environment Deployed

App Sync Health Image
pr-web-era-13405 ✅ Synced ✅ Healthy 8488694cbee8d57ee54c02ad2b574bd7db849212

Access: https://era-13405.dev.pamdas.org

View in ArgoCD

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (silent vs stepUp) 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 a stepUp primitive via loginWithRedirect.

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.

Comment thread src/hooks/useAuthRecovery.js
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>
@StephenWithPH
StephenWithPH requested a review from luixlive July 17, 2026 17:58
@StephenWithPH
StephenWithPH marked this pull request as ready for review July 17, 2026 17:58
@chrisj-er
chrisj-er self-requested a review July 17, 2026 19:15

@chrisj-er chrisj-er left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@StephenWithPH

Copy link
Copy Markdown
Contributor Author

@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 loginWithRedirect ever resolves without navigating, recoverAuth rejects (→ sign-out) and releases inFlight.stepUp rather than hanging forever. It lives on ERA-13733 rather than here because that branch adds the websocket consumer that makes the hang exposure real, and the fix covers both transports.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants