From db847e5d3c3de5b225ffd8eedd6b18b7fc1e67c4 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Thu, 23 Jul 2026 16:48:43 +0000 Subject: [PATCH 1/5] Add typed transient/terminal results to CookieSession.refresh() Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...refresh-and-seal-session-data.interface.ts | 32 ++++- src/user-management/session.spec.ts | 116 ++++++++++++++++++ src/user-management/session.ts | 101 ++++++++++++++- 3 files changed, 246 insertions(+), 3 deletions(-) diff --git a/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts b/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts index b46ee72cb..3dacdc850 100644 --- a/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts +++ b/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts @@ -5,15 +5,42 @@ export enum RefreshSessionFailureReason { INVALID_SESSION_COOKIE = 'invalid_session_cookie', NO_SESSION_COOKIE_PROVIDED = 'no_session_cookie_provided', - // API OauthErrors for refresh tokens + // Terminal API OauthErrors — the session is over and the user must + // re-authenticate. INVALID_GRANT = 'invalid_grant', MFA_ENROLLMENT = 'mfa_enrollment', SSO_REQUIRED = 'sso_required', + + // Transient failures — the refresh token is likely still valid and the + // request can be retried. Keep the existing session rather than signing + // the user out. + RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded', + TIMEOUT = 'timeout', + SERVER_ERROR = 'server_error', + NETWORK_ERROR = 'network_error', } type RefreshSessionFailedResponse = { authenticated: false; reason: RefreshSessionFailureReason; + /** + * Whether the refresh can safely be retried with the same refresh token. + * When `true` (e.g. a timeout, `5xx`, or `429`), keep the existing session + * and retry later. When `false` (e.g. `invalid_grant`), the failure is + * terminal and the user should be redirected to sign in. + */ + retryable: boolean; + /** + * Seconds the server asked the client to wait before retrying, parsed from + * the `Retry-After` response header. Only present for some retryable + * failures (e.g. a `429`). + */ + retryAfter?: number; + /** + * The underlying error, exposed for logging. Only present for retryable + * failures. + */ + error?: unknown; }; type RefreshSessionSuccessResponse = Omit< @@ -28,4 +55,5 @@ type RefreshSessionSuccessResponse = Omit< }; export type RefreshSessionResponse = - RefreshSessionFailedResponse | RefreshSessionSuccessResponse; + | RefreshSessionFailedResponse + | RefreshSessionSuccessResponse; diff --git a/src/user-management/session.spec.ts b/src/user-management/session.spec.ts index d41ecec24..fbe5e7d26 100644 --- a/src/user-management/session.spec.ts +++ b/src/user-management/session.spec.ts @@ -5,6 +5,11 @@ import { sealData } from '../common/crypto/seal'; import userFixture from './fixtures/user.json'; import fetch from 'jest-fetch-mock'; import { fetchOnce } from '../common/utils/test-utils'; +import { + GenericServerException, + OauthException, + RateLimitExceededException, +} from '../common/exceptions'; jest.mock('jose', () => ({ ...jest.requireActual('jose'), @@ -175,6 +180,117 @@ describe('Session', () => { expect(response).toEqual({ authenticated: false, reason: 'invalid_session_cookie', + retryable: false, + }); + }); + + describe('when the refresh fails', () => { + const accessToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJzdWIiOiAiMTIzNDU2Nzg5MCIsCiAgIm5hbWUiOiAiSm9obiBEb2UiLAogICJpYXQiOiAxNTE2MjM5MDIyLAogICJzaWQiOiAic2Vzc2lvbl8xMjMiLAogICJvcmdfaWQiOiAib3JnXzEyMyIsCiAgInJvbGUiOiAibWVtYmVyIiwKICAicGVybWlzc2lvbnMiOiBbInBvc3RzOmNyZWF0ZSIsICJwb3N0czpkZWxldGUiXQp9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + const cookiePassword = 'alongcookiesecretmadefortestingsessions'; + + async function loadSession() { + const workosNoRetries = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', { + clientId: 'client_123', + maxRetries: 0, + }); + + const sessionData = await sealData( + { + accessToken, + refreshToken: 'def456', + user: { + object: 'user', + id: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS', + email: 'test01@example.com', + }, + }, + { password: cookiePassword }, + ); + + return workosNoRetries.userManagement.loadSealedSession({ + sessionData, + cookiePassword, + }); + } + + it('returns a terminal, non-retryable result for invalid_grant', async () => { + fetchOnce( + { error: 'invalid_grant', error_description: 'Invalid refresh token.' }, + { status: 400 }, + ); + + const session = await loadSession(); + const response = await session.refresh(); + + expect(response).toEqual({ + authenticated: false, + reason: 'invalid_grant', + retryable: false, + }); + }); + + it('returns a retryable result with retryAfter for a 429', async () => { + fetchOnce( + { + error: 'too_many_requests', + error_description: 'Could not process refresh token. Please retry.', + }, + { status: 429, headers: { 'Retry-After': '1' } }, + ); + + const session = await loadSession(); + const response = await session.refresh(); + + expect(response).toEqual({ + authenticated: false, + reason: 'rate_limit_exceeded', + retryable: true, + retryAfter: 1, + error: expect.any(RateLimitExceededException), + }); + }); + + it('returns a retryable timeout result for a 408', async () => { + fetchOnce({ error: 'Request timeout' }, { status: 408 }); + + const session = await loadSession(); + const response = await session.refresh(); + + expect(response).toEqual({ + authenticated: false, + reason: 'timeout', + retryable: true, + error: expect.any(OauthException), + }); + }); + + it('returns a retryable server_error result for a 5xx', async () => { + fetchOnce({ message: 'Service unavailable' }, { status: 503 }); + + const session = await loadSession(); + const response = await session.refresh(); + + expect(response).toEqual({ + authenticated: false, + reason: 'server_error', + retryable: true, + error: expect.any(GenericServerException), + }); + }); + + it('returns a retryable network_error result for a network failure', async () => { + fetch.mockRejectOnce(new TypeError('Network request failed')); + + const session = await loadSession(); + const response = await session.refresh(); + + expect(response).toEqual({ + authenticated: false, + reason: 'network_error', + retryable: true, + error: expect.any(Error), + }); }); }); diff --git a/src/user-management/session.ts b/src/user-management/session.ts index d2f2473a6..9234e39c2 100644 --- a/src/user-management/session.ts +++ b/src/user-management/session.ts @@ -1,5 +1,7 @@ // @oagen-ignore-file +import { GenericServerException } from '../common/exceptions/generic-server.exception'; import { OauthException } from '../common/exceptions/oauth.exception'; +import { RateLimitExceededException } from '../common/exceptions/rate-limit-exceeded.exception'; import { UserManagementAccessToken, AuthenticateWithSessionCookieFailedResponse, @@ -122,6 +124,7 @@ export class CookieSession { return { authenticated: false, reason: RefreshSessionFailureReason.INVALID_SESSION_COOKIE, + retryable: false, }; } @@ -182,9 +185,10 @@ export class CookieSession { impersonator: session.impersonator, }; } catch (error) { + // Terminal authentication failures — the session is over. Surface a + // typed unauthenticated result so callers redirect to sign in. if ( error instanceof OauthException && - // TODO: Add additional known errors and remove re-throw (error.error === RefreshSessionFailureReason.INVALID_GRANT || error.error === RefreshSessionFailureReason.MFA_ENROLLMENT || error.error === RefreshSessionFailureReason.SSO_REQUIRED) @@ -192,9 +196,18 @@ export class CookieSession { return { authenticated: false, reason: error.error, + retryable: false, }; } + // Transient/operational failures — the refresh token is likely still + // valid. Surface a retryable result so callers keep the existing session + // and retry later rather than signing the user out. + const retryableFailure = classifyRetryableRefreshError(error); + if (retryableFailure) { + return retryableFailure; + } + throw error; } } @@ -247,3 +260,89 @@ export class CookieSession { } } } + +/** + * Classifies an error thrown while refreshing as a transient, retryable + * failure, or returns `null` when the error is not recognized as retryable + * (and should be rethrown). Retries at the HTTP transport layer are already + * exhausted by the time an error reaches here. + */ +function classifyRetryableRefreshError( + error: unknown, +): RefreshSessionResponse | null { + // 429 — the server asked us to back off (e.g. short-lived refresh + // contention). Checked first to surface the parsed `Retry-After`. + if (error instanceof RateLimitExceededException) { + return { + authenticated: false, + reason: RefreshSessionFailureReason.RATE_LIMIT_EXCEEDED, + retryable: true, + retryAfter: error.retryAfter ?? undefined, + error, + }; + } + + // Classify by HTTP status regardless of which exception wraps it — a request + // timeout, for example, surfaces as an `OauthException` (status 408) because + // the transport gives it an `error` body. + const status = getErrorStatus(error); + + if (status === 429) { + return { + authenticated: false, + reason: RefreshSessionFailureReason.RATE_LIMIT_EXCEEDED, + retryable: true, + error, + }; + } + + if (status === 408) { + return { + authenticated: false, + reason: RefreshSessionFailureReason.TIMEOUT, + retryable: true, + error, + }; + } + + if (status !== undefined && status >= 500) { + return { + authenticated: false, + reason: RefreshSessionFailureReason.SERVER_ERROR, + retryable: true, + error, + }; + } + + // Network-level failures (dropped connection, DNS, connection reset) surface + // from the transport as a `TypeError`, which the client rewraps as a generic + // `Error` with the original attached as `cause`. + if (isNetworkError(error)) { + return { + authenticated: false, + reason: RefreshSessionFailureReason.NETWORK_ERROR, + retryable: true, + error, + }; + } + + return null; +} + +function getErrorStatus(error: unknown): number | undefined { + if ( + error instanceof OauthException || + error instanceof GenericServerException + ) { + return error.status; + } + + return undefined; +} + +function isNetworkError(error: unknown): boolean { + return ( + error instanceof TypeError || + (error instanceof Error && error.cause instanceof TypeError) + ); +} From 55ab296147af981bc6365653f4f6680c2f3ddd36 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Thu, 23 Jul 2026 16:52:08 +0000 Subject: [PATCH 2/5] Apply prettier formatting Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../refresh-and-seal-session-data.interface.ts | 3 +-- src/user-management/session.spec.ts | 16 +++++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts b/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts index 3dacdc850..9342fc1a2 100644 --- a/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts +++ b/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts @@ -55,5 +55,4 @@ type RefreshSessionSuccessResponse = Omit< }; export type RefreshSessionResponse = - | RefreshSessionFailedResponse - | RefreshSessionSuccessResponse; + RefreshSessionFailedResponse | RefreshSessionSuccessResponse; diff --git a/src/user-management/session.spec.ts b/src/user-management/session.spec.ts index fbe5e7d26..83450ed3d 100644 --- a/src/user-management/session.spec.ts +++ b/src/user-management/session.spec.ts @@ -190,10 +190,13 @@ describe('Session', () => { const cookiePassword = 'alongcookiesecretmadefortestingsessions'; async function loadSession() { - const workosNoRetries = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', { - clientId: 'client_123', - maxRetries: 0, - }); + const workosNoRetries = new WorkOS( + 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + { + clientId: 'client_123', + maxRetries: 0, + }, + ); const sessionData = await sealData( { @@ -216,7 +219,10 @@ describe('Session', () => { it('returns a terminal, non-retryable result for invalid_grant', async () => { fetchOnce( - { error: 'invalid_grant', error_description: 'Invalid refresh token.' }, + { + error: 'invalid_grant', + error_description: 'Invalid refresh token.', + }, { status: 400 }, ); From 2a1fe6b137c1c0004d0c081762b20e33eba4ceb5 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Thu, 23 Jul 2026 16:54:48 +0000 Subject: [PATCH 3/5] Classify terminal mfa_enrollment/sso_required refresh failures Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/user-management/session.spec.ts | 38 ++++++++++++++++++++++++++++ src/user-management/session.ts | 39 +++++++++++++++++++++++------ 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/user-management/session.spec.ts b/src/user-management/session.spec.ts index 83450ed3d..05fea30fa 100644 --- a/src/user-management/session.spec.ts +++ b/src/user-management/session.spec.ts @@ -236,6 +236,44 @@ describe('Session', () => { }); }); + it('returns a terminal, non-retryable result for sso_required', async () => { + fetchOnce( + { + error: 'sso_required', + error_description: 'User must authenticate via SSO.', + }, + { status: 403 }, + ); + + const session = await loadSession(); + const response = await session.refresh(); + + expect(response).toEqual({ + authenticated: false, + reason: 'sso_required', + retryable: false, + }); + }); + + it('returns a terminal, non-retryable result for mfa_enrollment', async () => { + fetchOnce( + { + code: 'mfa_enrollment', + message: 'User must enroll in MFA.', + }, + { status: 403 }, + ); + + const session = await loadSession(); + const response = await session.refresh(); + + expect(response).toEqual({ + authenticated: false, + reason: 'mfa_enrollment', + retryable: false, + }); + }); + it('returns a retryable result with retryAfter for a 429', async () => { fetchOnce( { diff --git a/src/user-management/session.ts b/src/user-management/session.ts index 9234e39c2..03ec43187 100644 --- a/src/user-management/session.ts +++ b/src/user-management/session.ts @@ -1,4 +1,5 @@ // @oagen-ignore-file +import { AuthenticationException } from '../common/exceptions/authentication.exception'; import { GenericServerException } from '../common/exceptions/generic-server.exception'; import { OauthException } from '../common/exceptions/oauth.exception'; import { RateLimitExceededException } from '../common/exceptions/rate-limit-exceeded.exception'; @@ -187,15 +188,11 @@ export class CookieSession { } catch (error) { // Terminal authentication failures — the session is over. Surface a // typed unauthenticated result so callers redirect to sign in. - if ( - error instanceof OauthException && - (error.error === RefreshSessionFailureReason.INVALID_GRANT || - error.error === RefreshSessionFailureReason.MFA_ENROLLMENT || - error.error === RefreshSessionFailureReason.SSO_REQUIRED) - ) { + const terminalReason = classifyTerminalRefreshError(error); + if (terminalReason) { return { authenticated: false, - reason: error.error, + reason: terminalReason, retryable: false, }; } @@ -267,6 +264,34 @@ export class CookieSession { * (and should be rethrown). Retries at the HTTP transport layer are already * exhausted by the time an error reaches here. */ +function classifyTerminalRefreshError( + error: unknown, +): RefreshSessionFailureReason | null { + // `invalid_grant` is not an authentication-error code, so the client wraps it + // as an `OauthException`. + if ( + error instanceof OauthException && + error.error === RefreshSessionFailureReason.INVALID_GRANT + ) { + return RefreshSessionFailureReason.INVALID_GRANT; + } + + // `mfa_enrollment` and `sso_required` are authentication-error codes, so + // `handleHttpError` wraps them as an `AuthenticationException` (a subclass of + // `GenericServerException`), never an `OauthException`. + if (error instanceof AuthenticationException) { + const code: string = error.code; + if (code === RefreshSessionFailureReason.MFA_ENROLLMENT) { + return RefreshSessionFailureReason.MFA_ENROLLMENT; + } + if (code === RefreshSessionFailureReason.SSO_REQUIRED) { + return RefreshSessionFailureReason.SSO_REQUIRED; + } + } + + return null; +} + function classifyRetryableRefreshError( error: unknown, ): RefreshSessionResponse | null { From 7c91dd7fa35a10acd1c9725439f316ef8e3cae6b Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Thu, 23 Jul 2026 17:00:36 +0000 Subject: [PATCH 4/5] Use discriminated union for refresh failure results Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...refresh-and-seal-session-data.interface.ts | 30 ++++++++++++------- src/user-management/session.ts | 8 ++--- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts b/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts index 9342fc1a2..21b513096 100644 --- a/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts +++ b/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts @@ -20,16 +20,24 @@ export enum RefreshSessionFailureReason { NETWORK_ERROR = 'network_error', } -type RefreshSessionFailedResponse = { +/** + * A terminal refresh failure: the session is over (e.g. `invalid_grant`) and + * the user should be redirected to sign in. + */ +type RefreshSessionTerminalFailedResponse = { authenticated: false; reason: RefreshSessionFailureReason; - /** - * Whether the refresh can safely be retried with the same refresh token. - * When `true` (e.g. a timeout, `5xx`, or `429`), keep the existing session - * and retry later. When `false` (e.g. `invalid_grant`), the failure is - * terminal and the user should be redirected to sign in. - */ - retryable: boolean; + retryable: false; +}; + +/** + * A transient refresh failure: the refresh token is likely still valid (e.g. a + * timeout, `5xx`, or `429`), so keep the existing session and retry later. + */ +type RefreshSessionRetryableFailedResponse = { + authenticated: false; + reason: RefreshSessionFailureReason; + retryable: true; /** * Seconds the server asked the client to wait before retrying, parsed from * the `Retry-After` response header. Only present for some retryable @@ -37,12 +45,14 @@ type RefreshSessionFailedResponse = { */ retryAfter?: number; /** - * The underlying error, exposed for logging. Only present for retryable - * failures. + * The underlying error, exposed for logging. */ error?: unknown; }; +type RefreshSessionFailedResponse = + RefreshSessionTerminalFailedResponse | RefreshSessionRetryableFailedResponse; + type RefreshSessionSuccessResponse = Omit< AuthenticateWithSessionCookieSuccessResponse, // accessToken is available in the session object and with session diff --git a/src/user-management/session.ts b/src/user-management/session.ts index 03ec43187..688a73e74 100644 --- a/src/user-management/session.ts +++ b/src/user-management/session.ts @@ -259,10 +259,10 @@ export class CookieSession { } /** - * Classifies an error thrown while refreshing as a transient, retryable - * failure, or returns `null` when the error is not recognized as retryable - * (and should be rethrown). Retries at the HTTP transport layer are already - * exhausted by the time an error reaches here. + * Classifies an error thrown while refreshing as a terminal authentication + * failure — the session is over and the user must re-authenticate — returning + * the matching failure reason, or `null` when the error is not a recognized + * terminal failure (and may still be retryable or should be rethrown). */ function classifyTerminalRefreshError( error: unknown, From 11d7de42e142b9f24d736e345d194059e187813d Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Thu, 23 Jul 2026 17:10:35 +0000 Subject: [PATCH 5/5] Narrow failure reason by retryable discriminant Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../refresh-and-seal-session-data.interface.ts | 17 +++++++++++++++-- src/user-management/session.ts | 3 ++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts b/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts index 21b513096..9fd9cb75b 100644 --- a/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts +++ b/src/user-management/interfaces/refresh-and-seal-session-data.interface.ts @@ -20,13 +20,26 @@ export enum RefreshSessionFailureReason { NETWORK_ERROR = 'network_error', } +export type TerminalRefreshSessionFailureReason = + | RefreshSessionFailureReason.INVALID_SESSION_COOKIE + | RefreshSessionFailureReason.NO_SESSION_COOKIE_PROVIDED + | RefreshSessionFailureReason.INVALID_GRANT + | RefreshSessionFailureReason.MFA_ENROLLMENT + | RefreshSessionFailureReason.SSO_REQUIRED; + +export type RetryableRefreshSessionFailureReason = + | RefreshSessionFailureReason.RATE_LIMIT_EXCEEDED + | RefreshSessionFailureReason.TIMEOUT + | RefreshSessionFailureReason.SERVER_ERROR + | RefreshSessionFailureReason.NETWORK_ERROR; + /** * A terminal refresh failure: the session is over (e.g. `invalid_grant`) and * the user should be redirected to sign in. */ type RefreshSessionTerminalFailedResponse = { authenticated: false; - reason: RefreshSessionFailureReason; + reason: TerminalRefreshSessionFailureReason; retryable: false; }; @@ -36,7 +49,7 @@ type RefreshSessionTerminalFailedResponse = { */ type RefreshSessionRetryableFailedResponse = { authenticated: false; - reason: RefreshSessionFailureReason; + reason: RetryableRefreshSessionFailureReason; retryable: true; /** * Seconds the server asked the client to wait before retrying, parsed from diff --git a/src/user-management/session.ts b/src/user-management/session.ts index 688a73e74..3f257f329 100644 --- a/src/user-management/session.ts +++ b/src/user-management/session.ts @@ -12,6 +12,7 @@ import { RefreshSessionFailureReason, RefreshSessionResponse, SessionCookieData, + TerminalRefreshSessionFailureReason, } from './interfaces'; import { UserManagement } from './user-management'; import { unsealData } from '../common/crypto/seal'; @@ -266,7 +267,7 @@ export class CookieSession { */ function classifyTerminalRefreshError( error: unknown, -): RefreshSessionFailureReason | null { +): TerminalRefreshSessionFailureReason | null { // `invalid_grant` is not an authentication-error code, so the client wraps it // as an `OauthException`. if (