ERA-13733: RFC 9470 MFA step-up over the rt_api websocket#1663
Open
StephenWithPH wants to merge 2 commits into
Open
ERA-13733: RFC 9470 MFA step-up over the rt_api websocket#1663StephenWithPH wants to merge 2 commits into
StephenWithPH wants to merge 2 commits into
Conversation
🚀 PR Environment Deployed
Access: https://era-13733.dev.pamdas.org |
Contributor
There was a problem hiding this comment.
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_authorization401s carryingstatus.www_authenticatethroughrecoverAuth({ 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);
});
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>
StephenWithPH
marked this pull request as ready for review
July 20, 2026 18:14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
ERA-13733
Handles the RFC 9470 MFA step-up challenge over the
rt_apiwebsocket, the socket sibling of the REST handler in #1659 (ERA-13405). On arequire_mfasite the server now surfaces the challenge on aresp_authorization401 asstatus.www_authenticate(a rawWWW-Authenticatestring — das#4085 / ERA-13732). This routes it through the sharedauth-recoveryunit'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).
Commits
useRealTimeImplementation'sresp_authorizationhandler readsstatus.www_authenticate; a step-up challenge routes torecoverAuth({ stepUp: true, challenge })(redirect — never silent-renews), an ordinary 401 keeps the ERA-13685 silent-renew path. Step-up bypasses the per-bindauthRetriedguard (stepUp || !authRetried) so a renewed-but-MFA-stale token escalates to step-up rather than signing out. Mirrors the REST interceptor.How it works (end to end)
Socket step-up 401 → handler parses
status.www_authenticate→recoverAuth({ stepUp })→loginWithRedirect(the interactive primitive owned by ERA-13405) → user completes MFA → the redirect callback re-bootstraps with the MFA token. The rebuilt socket'sconnecthandler 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
stepUpprimitive resolvesloginWithRedirectand then stays pending (new Promise(() => {})) so recovery doesn't replay before the page unloads — which meansrecoverAuth'sinFlight.stepUplatch relied on unload to clear. IfloginWithRedirectever 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_000via the existingwithTimeout(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
stepUpprimitive this reuses.auth-recoveryunit + socket silent-renew) — merged.WWW-Authenticatestring instatus.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 arequire_mfaenv, hence thedeploylabel.Behavior notes — worth discussing
inFlight.stepUpflight. Not re-asserted at the socket layer here (recoverAuthis mocked in the socket test); it's pinned inauth-recovery.test.js.stepUpprimitive). Benign at the defaultmfa_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
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_authenticatestill silent-renews; and the guard-bypass escalation (a silently-renewed socket then 401s with a step-up challenge → step-up, not sign-out).utils/auth-recovery.test.js): the watchdog rejects a step-up whose redirect never navigates, does not dispatch a token, and releasesinFlight.stepUpso a later step-up retries.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