Skip to content

ERA-13733: RFC 9470 MFA step-up over the rt_api websocket#1663

Open
StephenWithPH wants to merge 2 commits into
developfrom
ERA-13733
Open

ERA-13733: RFC 9470 MFA step-up over the rt_api websocket#1663
StephenWithPH wants to merge 2 commits into
developfrom
ERA-13733

Conversation

@StephenWithPH

@StephenWithPH StephenWithPH commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What & why

ERA-13733

Handles the RFC 9470 MFA step-up challenge over the rt_api websocket, the socket sibling of the REST handler in #1659 (ERA-13405). On a require_mfa site the server now surfaces the challenge on a resp_authorization 401 as status.www_authenticate (a raw WWW-Authenticate string — das#4085 / ERA-13732). This routes it through the shared auth-recovery unit's interactive step-up instead of a silent-renew (which is same-ACR and can't satisfy MFA) followed by a blind sign-out.

Also hardens the shared unit with a step-up redirect-handoff watchdog (raised in review on #1659).

Stacked on #1659. Base is ERA-13405, so this diff is just the two ERA-13733 commits. GitHub will auto-retarget the base to develop when #1659 merges; this PR must merge after #1659.

Commits

  1. route step-up 401s over the websocketuseRealTimeImplementation's resp_authorization handler reads status.www_authenticate; a step-up challenge routes to recoverAuth({ stepUp: true, challenge }) (redirect — never silent-renews), an ordinary 401 keeps the ERA-13685 silent-renew path. Step-up bypasses the per-bind authRetried guard (stepUp || !authRetried) so a renewed-but-MFA-stale token escalates to step-up rather than signing out. Mirrors the REST interceptor.
  2. bound the step-up redirect handoff (watchdog) — see below.

How it works (end to end)

Socket step-up 401 → handler parses status.www_authenticaterecoverAuth({ stepUp })loginWithRedirect (the interactive primitive owned by ERA-13405) → user completes MFA → the redirect callback re-bootstraps with the MFA token. The rebuilt socket's connect handler reads the fresh token from the store and re-authorizes — so no bespoke socket recovery is needed. Because the flight is mode-keyed single-flight, a concurrent REST + socket step-up coalesces into one redirect.

The watchdog (addresses @chrisj-er's note on #1659)

The stepUp primitive resolves loginWithRedirect and then stays pending (new Promise(() => {})) so recovery doesn't replay before the page unloads — which means recoverAuth's inFlight.stepUp latch relied on unload to clear. If loginWithRedirect ever resolved without navigating, the latch stuck forever and every later step-up 401 hung with no sign-out fallback.

Fix: time-box the step-up flight with a generous STEP_UP_REDIRECT_TIMEOUT_MS = 60_000 via the existing withTimeout (silent renewal stays 30s). A successful redirect unloads the page in well under a second, so the bound can only fire when the redirect never navigated — converting an indefinite hang into a recoverable sign-out and releasing the latch. It never truncates the user's MFA, which happens on the Auth0 page after unload (not on this pending promise).

Dependencies / status

  • Stacked on ERA-13405: RFC 9470 MFA step-up handler in the REST API client #1659 (ERA-13405) — the REST step-up + the shared parsers, single-flight, and interactive stepUp primitive this reuses.
  • Builds on ERA-13685 (shared auth-recovery unit + socket silent-renew) — merged.
  • Server socket emission ERA-13732 (das#4085) is In Pull Request. The payload is a raw WWW-Authenticate string in status.www_authenticate, byte-compatible with the HTTP header so the REST parser (isStepUpChallenge/parseAuthChallenge) is reused unchanged.
  • require_mfa (ERA-13387) is In Test — end-to-end exercise needs a require_mfa env, hence the deploy label.

Behavior notes — worth discussing

  • AC3 (concurrent REST + socket → a single step-up) falls out of ERA-13405's mode-keyed single-flight; the socket handler joins the same inFlight.stepUp flight. Not re-asserted at the socket layer here (recoverAuth is mocked in the socket test); it's pinned in auth-recovery.test.js.
  • Deferred hardening (core-review finding). Step-up loop-prevention is currently implicit: both transports bypass their anti-loop guards for step-up, safe only because the redirect primitive never resolves with a token (it navigates away, or the watchdog rejects → sign-out). This is fine for the redirect variant shipping here. If the popup escape-hatch variant is ever adopted (it resolves in-page with a token), an explicit "step-up attempted once per cycle" marker is required, or a repeated step-up 401 would loop. Tied to the popup variant, deliberately not in this PR.
  • The redirect discards unsaved in-flight state — carries over verbatim from ERA-13405: RFC 9470 MFA step-up handler in the REST API client #1659 (same stepUp primitive). Benign at the default mfa_max_age_seconds (365 days); a short per-tenant window re-introduces mid-form step-ups. Escape hatch is the popup variant (see above).

Testing

  • Socket (withSocketConnection/index.test.js): step-up challenge routes to the interactive path (exact parsed { stepUp, challenge } args, no silent-renew, no sign-out); an ordinary (non-step-up) www_authenticate still silent-renews; and the guard-bypass escalation (a silently-renewed socket then 401s with a step-up challenge → step-up, not sign-out).
  • Shared unit (utils/auth-recovery.test.js): the watchdog rejects a step-up whose redirect never navigates, does not dispatch a token, and releases inFlight.stepUp so a later step-up retries.
  • Full suite green (2891).

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 StephenWithPH self-assigned this Jul 17, 2026
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

🚀 PR Environment Deployed

App Sync Health Image
pr-web-era-13733 ✅ Synced ✅ Healthy 529d93b234741a9bf4e57a3b077a8a5f2c13989c

Access: https://era-13733.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 MFA “step-up” handling to the rt_api websocket authorization flow by reusing the shared auth-recovery challenge parsing and step-up recovery path, and hardens step-up recovery with a redirect-handoff watchdog so a failed navigation can’t wedge auth recovery indefinitely.

Changes:

  • Route websocket resp_authorization 401s carrying status.www_authenticate through recoverAuth({ stepUp: true, challenge }) when the RFC 9470 step-up challenge is detected.
  • Add a step-up redirect watchdog by time-boxing step-up recovery flights (separate from silent-renew timeout) to prevent indefinite hangs when redirect doesn’t navigate.
  • Expand websocket and auth-recovery unit tests to cover step-up routing, escalation behavior, and watchdog behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/withSocketConnection/useRealTimeImplementation/index.js Detects step-up challenges on websocket 401s and routes them to interactive step-up recovery, bypassing the once-per-cycle guard for step-up.
src/withSocketConnection/index.test.js Adds websocket unit coverage for step-up routing, non-step-up 401 behavior, and escalation to step-up after silent-renew.
src/utils/auth-recovery.test.js Updates auth-recovery tests to verify step-up redirect watchdog timeout and in-flight release behavior.
src/utils/auth-recovery.js Adds a distinct timeout for step-up redirect handoff by time-boxing step-up flights via withTimeout.
Comments suppressed due to low confidence (1)

src/utils/auth-recovery.js:42

  • The timeout rejection message is always "renewal timed out", which is ambiguous now that both silent renew and step-up redirect handoff are time-boxed. Including a label in the error makes logs/actionable telemetry clearer when diagnosing production failures.
const withTimeout = async (promise, ms) => {
  let timeoutId;
  const timeout = new Promise((_, reject) => {
    timeoutId = setTimeout(() => reject(new Error('auth-recovery: renewal timed out')), ms);
  });

Comment thread src/utils/auth-recovery.js
Base automatically changed from ERA-13405 to develop July 20, 2026 17:52
On a resp_authorization 401, read the RFC 9470 step-up challenge the server
surfaces in status.www_authenticate (ERA-13732). When present, route to the
shared unit's interactive step-up (recoverAuth({ stepUp: true, challenge })),
which redirects rather than silent-renewing — a same-ACR silent token cannot
satisfy the MFA challenge. Step-up bypasses the once-per-cycle authRetried
guard so a renewed-but-MFA-stale token escalates instead of signing out.
Ordinary 401s keep the ERA-13685 silent-renew path unchanged.

Reuses the parsers, mode-keyed single-flight, and interactive stepUp primitive
from ERA-13405, so a concurrent REST + socket step-up coalesces to one step-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gns out instead of hanging

The stepUp primitive resolves loginWithRedirect and then stays pending until the
page unloads, so recoverAuth's inFlight.stepUp latch relied on unload to clear.
If loginWithRedirect ever resolves without navigating, the latch stuck forever
and every later step-up 401 hung with no sign-out fallback (raised in review on
the ERA-13405 PR).

Time-box the step-up flight with a generous redirect-handoff watchdog. A
successful redirect unloads well before it fires, so it only trips when the
redirect never navigated — converting an indefinite hang into a recoverable
sign-out and releasing the latch so a later step-up retries. Applies to both
the REST interceptor and the socket handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

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

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@StephenWithPH
StephenWithPH marked this pull request as ready for review July 20, 2026 18:14

@luixlive luixlive 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.

As I have mentioned in other PRs, we are getting big verbose blocks of comments. This PR again has a few of them. The code itself looks right, I'd just clean some of the comments 😅

// until the page unloads — a successful redirect unloads in well under a second, so this
// bound only fires when the redirect never navigated, converting an indefinite hang into a
// recoverable sign-out. It never truncates the user's MFA, which happens on the Auth0 page
// after unload (not on this pending promise), so the step-up bound is generous.

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.

Ok this comment is definitely too big haha

// Each flight is time-boxed so a stalled recovery can't hang forever. Silent renewal
// guards a network black-hole. Step-up guards the redirect handoff.

I feel each Claude model comes trained to add more and more verbose comments 🤣 We'll end up having more commentary than code.

try {
await recoverAuth();
// Silent-renew is same-ACR and can't satisfy MFA, so a step-up 401 must route to
// the interactive path; it redirects and never resolves (the page navigates away).

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.

Two separate comments explain "step-up bypasses the guard because it redirects instead of looping." We could merge them into:

// Step-up bypasses the once-per-cycle guard: it redirects rather than looping, and a
// renewed-but-MFA-stale token surfaces the challenge only on the re-authorize.

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