ERA-13733: RFC 9470 MFA step-up over the rt_api websocket#1663
ERA-13733: RFC 9470 MFA step-up over the rt_api websocket#1663StephenWithPH wants to merge 2 commits into
Conversation
🚀 PR Environment Deployed
Access: https://era-13733.dev.pamdas.org |
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>
luixlive
left a comment
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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). |
There was a problem hiding this comment.
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.
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