-
Notifications
You must be signed in to change notification settings - Fork 2
fix: Preserve session on transient refresh failures #79
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,12 +28,70 @@ export type TypedResponse<T> = Response & { | |
| }; | ||
|
|
||
| export class SessionRefreshError extends Error { | ||
| /** | ||
| * Whether the refresh failed for a transient reason (network error, timeout, | ||
| * 429, or 5xx) rather than a terminal one (the refresh token is dead). When | ||
| * `true`, the existing session is still valid and should be preserved and | ||
| * retried rather than destroyed. | ||
| */ | ||
| readonly isTransient: boolean; | ||
|
|
||
| constructor(cause: unknown) { | ||
| super('Session refresh error', { cause }); | ||
| this.name = 'SessionRefreshError'; | ||
| this.isTransient = isTransientRefreshError(cause); | ||
| } | ||
| } | ||
|
|
||
| // The WorkOS SDK's HTTP client already retries these with backoff + jitter | ||
| // internally. If one of these still surfaces, the failure is transient rather | ||
| // than a dead refresh token: request timeouts (normalized to 408), rate limits | ||
| // (429), and 5xx. | ||
| const RETRYABLE_REFRESH_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); | ||
|
|
||
| // A network-level fetch failure surfaces as a TypeError ("fetch failed" / | ||
| // "Failed to fetch"). Match its message so an unrelated programming TypeError | ||
| // (e.g. from a helper after a successful exchange) is not misclassified. | ||
| const NETWORK_ERROR_MESSAGE = /fetch failed|failed to fetch|network|load failed|terminated/i; | ||
|
|
||
| // A raw network TypeError is not an HttpClientError, so the WorkOS SDK re-wraps | ||
| // it in a plain Error whose `cause` is the original TypeError. Follow the cause | ||
| // chain to recognize it. | ||
| function isNetworkError(error: unknown): boolean { | ||
| if (error instanceof TypeError) { | ||
| return NETWORK_ERROR_MESSAGE.test(error.message); | ||
| } | ||
|
|
||
| if (error instanceof Error && error.cause != null && error.cause !== error) { | ||
| return isNetworkError(error.cause); | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Classifies a refresh failure as transient (retryable) rather than terminal. | ||
| * Transient failures carry a retryable numeric `status` (408/429/5xx, mirroring | ||
| * the SDK's own retry set) or are network failures (a "fetch failed" `TypeError`, | ||
| * possibly wrapped by the SDK with the original `TypeError` as its `cause`). | ||
| * Anything else (a terminal `invalid_grant` at 400, a 401, or an unrecognized | ||
| * error) is treated as terminal. | ||
| */ | ||
| export function isTransientRefreshError(error: unknown): boolean { | ||
| // A known HTTP status is authoritative: a retryable code is transient, and | ||
| // any other status (e.g. a terminal 400 `invalid_grant`) is terminal. Return | ||
| // eagerly so a terminal response is never reclassified as transient by the | ||
| // network-cause fallback below. | ||
| if (typeof error === 'object' && error !== null && 'status' in error) { | ||
| const { status } = error; | ||
| if (typeof status === 'number') { | ||
| return RETRYABLE_REFRESH_STATUS_CODES.has(status); | ||
| } | ||
| } | ||
|
|
||
| return isNetworkError(error); | ||
| } | ||
|
|
||
| /** | ||
| * This function is used to refresh the session by using the refresh token. | ||
| * It will authenticate the user with the refresh token and return a new session object. | ||
|
|
@@ -448,6 +506,7 @@ export async function authkitLoader<Data = unknown>( | |
| error: error.cause, | ||
| request, | ||
| sessionData: cookieSession, | ||
| isTransient: error.isTransient, | ||
| }); | ||
|
|
||
| if (result instanceof Response) { | ||
|
|
@@ -463,6 +522,16 @@ export async function authkitLoader<Data = unknown>( | |
|
|
||
| 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']]] }); | ||
| } | ||
|
Comment on lines
523
to
+533
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 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 ( Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't users with an active cookie get bounced off the sign in URL?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 ( So today, on an
That's backwards, and it's exactly your point. (The access token is expired by the time we refresh — 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: Want me to push this? It's a small change plus tests (transient + |
||
|
|
||
| throw redirect(url, { | ||
| headers: [ | ||
| ['Set-Cookie', await destroySession(cookieSession)], | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.