fix: Preserve session on transient refresh failures#79
Conversation
authkitLoader destroyed the sealed session and redirected to sign-in on any SessionRefreshError, including transient failures (network error, request timeout, 429, or 5xx) that survived the SDK's internal retries. During a brief outage this discarded a still-valid refresh token and forced re-authentication. Classify the wrapped refresh error and only destroy the session for a terminal failure. On a transient failure keep the sealed cookie (the redirect still sets the PKCE verifier cookie) so a later request refreshes successfully once the condition clears. SessionRefreshError now exposes an isTransient flag, also surfaced on onSessionRefreshError. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Original prompt from madison.packer
|
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
| const returnPathname = getReturnPathname(request.url); | ||
| const { url, headers: authHeaders } = await getAuthorizationUrl({ returnPathname, request }); | ||
|
|
||
| // Only destroy the session for a terminal failure. A transient failure | ||
| // (network error, timeout, 429, or 5xx that survived the SDK's internal | ||
| // retries) leaves the refresh token valid, so keep the sealed cookie and | ||
| // let a later request refresh successfully rather than forcing the user | ||
| // to re-authenticate. | ||
| if (error.isTransient) { | ||
| throw redirect(url, { headers: [['Set-Cookie', authHeaders['Set-Cookie']]] }); | ||
| } |
There was a problem hiding this comment.
🔍 Transient failures still redirect to the sign-in URL
On a transient refresh failure the loader still throws a redirect to the WorkOS authorization URL (src/session.ts:527-528); it only avoids destroying the sealed cookie. Since the access token is expired (that is why refresh was attempted), the current request cannot be served with valid auth, so the user is still bounced to re-authenticate. The stated benefit ('a later request refreshes successfully once the condition clears') therefore only applies when the redirect is not followed (e.g. background/data requests) — for a normal document navigation the user will complete the WorkOS flow and get a fresh session anyway. This matches the PR description's code snippet and the new tests assert a 302 with the cookie preserved, so it appears intentional, but reviewers should confirm this is the desired UX parity with the authkit-remix/authkit-nextjs ports.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Intentional, and it matches the authkit-remix/authkit-nextjs ports. You're right that for a normal document navigation the user still completes the WorkOS redirect (the current request's access token is expired, so it can't be served regardless). The value of preserving the cookie is two-fold: (1) concurrent/background data requests (loaders, fetchers, revalidations) that fire during the same transient blip keep a valid sealed session and can succeed instead of all tearing it down, and (2) we never throw away a still-valid refresh token on a transient 5xx/429/network error — which is the BaseTen outage-lockout scenario where destroying the session while login is also degraded locks users out. Terminal invalid_grant still destroys as before.
There was a problem hiding this comment.
Don't users with an active cookie get bounced off the sign in URL?
There was a problem hiding this comment.
Good catch — yes, and it exposes an inconsistency that predates this PR. This catch block redirects to the sign-in URL unconditionally, but the no-session branch above (if (!session), line 422) only redirects when ensureSignedIn: true and otherwise returns UnauthorizedData so the route just renders logged-out.
So today, on an ensureSignedIn: false (optional-auth) route:
- a user with no session → renders logged-out, no bounce;
- a user with a valid, recoverable session that hits a transient refresh blip → gets bounced to WorkOS.
That's backwards, and it's exactly your point. (The access token is expired by the time we refresh — updateSession only refreshes a token that already failed verifyAccessToken — so we genuinely can't serve authenticated content for this request; but for a non-ensureSignedIn loader the right answer is to render logged-out for this one request, not to redirect.)
Proposed fix — mirror the no-session semantics in the catch:
if (error instanceof SessionRefreshError) {
const cookieSession = await getSession(request.headers.get('Cookie'));
// ...onSessionRefreshError callback unchanged...
// Terminal failure destroys the cookie; transient preserves it.
const setCookie = error.isTransient
? undefined
: await destroySession(cookieSession);
if (ensureSignedIn) {
const { url, headers: authHeaders } = await getAuthorizationUrl({ returnPathname, request });
const headers: [string, string][] = [];
if (setCookie) headers.push(['Set-Cookie', setCookie]);
headers.push(['Set-Cookie', authHeaders['Set-Cookie']]);
throw redirect(url, { headers });
}
// Not ensureSignedIn: render logged-out instead of bouncing. On a transient
// failure the sealed cookie is preserved so a later request refreshes.
const auth: UnauthorizedData = { user: null, impersonator: null, /* ...nulls... */ };
return await handleAuthLoader(loader, loaderArgs, auth); // + Set-Cookie header when terminal
}Net effect: ensureSignedIn: true still redirects (that's the contract), but optional-auth routes stop bouncing users with a recoverable session on a transient blip — the sealed cookie is kept and the next navigation/data request refreshes. This also brings react-router closer to the authkit-nextjs port, which returns { user: null } + authorizationUrl rather than force-redirecting.
Want me to push this? It's a small change plus tests (transient + ensureSignedIn: false → no redirect, cookie preserved; terminal + ensureSignedIn: false → no redirect, cookie destroyed). I held off since it's a UX call on your repo.
Greptile SummaryThis PR fixes a session destruction bug where any
Confidence Score: 5/5Safe to merge. The change correctly narrows session destruction to terminal failures only, and the key edge case (terminal status with a TypeError in the cause chain) is both handled by the early return and covered by a dedicated test. The isTransientRefreshError classification is correct: the numeric-status guard returns eagerly so a 400 invalid_grant that happens to wrap a fetch failed TypeError is still treated as terminal. The onSessionRefreshError callback receives isTransient for full consumer control. Tests parametrize five transient inputs, one terminal-with-network-cause input, and two straight terminal inputs, then separately verify the flag propagates to the callback. No session data is logged or leaked in error paths. No files require special attention. The only export not updated is refreshSession, which wraps the raw error in a plain Error rather than a SessionRefreshError, so callers of that function still need to call isTransientRefreshError themselves — but that function is now exported and this is a separate public API path outside the scope of this change. Important Files Changed
|
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Summary
authkitLoadercaught anySessionRefreshErrorand destroyed the sealed session + redirected to sign-in. That includes transient failures (network error, request timeout,429,5xx) that survive the SDK's internal retries — so a brief outage discards a still-valid refresh token and forces re-authentication (the BaseTen lockout pattern). This is the React Router v7 port of the same fix landed for authkit-remix (workos/authkit-remix#88) and authkit-nextjs (workos/authkit-nextjs#461).This classifies the wrapped refresh error and only destroys the session for a terminal failure. On a transient failure the sealed cookie is kept, so a later request refreshes successfully once the condition clears.
SessionRefreshErrornow carries anisTransientflag derived from the wrapped cause, using the SDK's own retry classification — a transient HTTP response carries a retryable numericstatus; a network failure surfaces as aTypeError(possibly re-wrapped by the SDK, so the classifier follows thecausechain):onSessionRefreshErrorstill runs for both cases and now receives the computedisTransient(parity with the thrown error), so consumers can override behavior. Terminalinvalid_grant/401/unrecognized errors still destroy the cookie and redirect.Test plan
npm test(162 tests pass),npm run lint,prettier --check, andtsc --noEmitall green.session.spec.tscoverage: parametrized transient cases (429,503,408, networkTypeError, SDK-wrapped network error) assertdestroySessionis not called and thedestroyed-session-cookieis not emitted (only the PKCE verifier cookie is); a terminalinvalid_grant(400) case asserts the session is destroyed; andonSessionRefreshErrorreceivesisTransient: true/falsefor transient/terminal failures respectively.Part of the AuthKit refresh-token DX work (server returns
429for transient refresh lock timeouts in workos/workos#66577; typed transient/terminalsession.refresh()in workos/workos-node#1663).Link to Devin session: https://app.devin.ai/sessions/fc39103abf694f90b1c92dd714a81461
Requested by: @m0tzy