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..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 @@ -5,17 +5,67 @@ 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 = { +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; }; +/** + * 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: RetryableRefreshSessionFailureReason; + retryable: true; + /** + * 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. + */ + 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.spec.ts b/src/user-management/session.spec.ts index d41ecec24..05fea30fa 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,161 @@ 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 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( + { + 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..3f257f329 100644 --- a/src/user-management/session.ts +++ b/src/user-management/session.ts @@ -1,5 +1,8 @@ // @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'; import { UserManagementAccessToken, AuthenticateWithSessionCookieFailedResponse, @@ -9,6 +12,7 @@ import { RefreshSessionFailureReason, RefreshSessionResponse, SessionCookieData, + TerminalRefreshSessionFailureReason, } from './interfaces'; import { UserManagement } from './user-management'; import { unsealData } from '../common/crypto/seal'; @@ -122,6 +126,7 @@ export class CookieSession { return { authenticated: false, reason: RefreshSessionFailureReason.INVALID_SESSION_COOKIE, + retryable: false, }; } @@ -182,19 +187,25 @@ export class CookieSession { impersonator: session.impersonator, }; } catch (error) { - 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) - ) { + // Terminal authentication failures — the session is over. Surface a + // typed unauthenticated result so callers redirect to sign in. + const terminalReason = classifyTerminalRefreshError(error); + if (terminalReason) { return { authenticated: false, - reason: error.error, + reason: terminalReason, + 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 +258,117 @@ export class CookieSession { } } } + +/** + * 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, +): TerminalRefreshSessionFailureReason | 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 { + // 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) + ); +}