diff --git a/packages/auth/src/login/standalone.test.ts b/packages/auth/src/login/standalone.test.ts new file mode 100644 index 0000000000..f25b11eb7d --- /dev/null +++ b/packages/auth/src/login/standalone.test.ts @@ -0,0 +1,170 @@ +/** + * Tests for the token-exchange retry in exchangeCodeForTokens, exercised through + * handleLoginCallback (the shared path used by both popup and redirect flows). + */ + +const trackMock = jest.fn(); + +jest.mock('@imtbl/metrics', () => ({ + track: (...args: unknown[]) => trackMock(...args), + getDetail: jest.fn(), + Detail: { RUNTIME_ID: 'runtime_id' }, +})); + +jest.mock('../utils/jwt', () => ({ + decodeJwtPayload: jest.fn(() => ({})), +})); + +// eslint-disable-next-line import/first +import { handleLoginCallback, type LoginConfig } from './standalone'; + +const CONFIG: LoginConfig = { + clientId: 'client-123', + redirectUri: 'https://app.example.com/callback', + authenticationDomain: 'https://auth.example.com', +}; + +const PKCE = { + state: 'state-xyz', + verifier: 'verifier-abc', + redirectUri: 'https://app.example.com/callback', +}; + +const TOKENS = { access_token: 'access-tok', refresh_token: 'refresh-tok' }; + +function mockResponse(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response; +} + +describe('exchangeCodeForTokens retry (via handleLoginCallback)', () => { + let fetchMock: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + window.sessionStorage.setItem('imtbl_pkce_data', JSON.stringify(PKCE)); + window.history.replaceState(null, '', '/?code=auth-code&state=state-xyz'); + fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + window.sessionStorage.clear(); + }); + + it('retries a 5xx and resolves on the next success', async () => { + fetchMock + .mockResolvedValueOnce(mockResponse(503, { error: 'unavailable' })) + .mockResolvedValueOnce(mockResponse(200, TOKENS)); + + const promise = handleLoginCallback(CONFIG); + await jest.advanceTimersByTimeAsync(2000); + const result = await promise; + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result?.accessToken).toBe('access-tok'); + }); + + it('emits telemetry for the retry and the recovery', async () => { + fetchMock + .mockResolvedValueOnce(mockResponse(503, { error: 'unavailable' })) + .mockResolvedValueOnce(mockResponse(200, TOKENS)); + + const promise = handleLoginCallback(CONFIG); + await jest.advanceTimersByTimeAsync(2000); + await promise; + + expect(trackMock).toHaveBeenCalledWith( + 'passport', + 'standaloneTokenExchangeFailed', + expect.objectContaining({ attempt: 1, reason: '503', willRetry: true }), + ); + expect(trackMock).toHaveBeenCalledWith( + 'passport', + 'standaloneTokenExchangeRecovered', + expect.objectContaining({ attempt: 2 }), + ); + }); + + it('aborts a stalled response body and retries', async () => { + // Headers arrive but the body never completes — the abort must still be armed, or the + // read hangs forever (fetch resolves on headers, not on the full body). + fetchMock.mockImplementationOnce((_url: string, init: RequestInit) => Promise.resolve({ + ok: true, + status: 200, + json: () => new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => { + reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })); + }); + }), + text: async () => '', + } as unknown as Response)); + fetchMock.mockResolvedValueOnce(mockResponse(200, TOKENS)); + + const promise = handleLoginCallback(CONFIG); + await jest.advanceTimersByTimeAsync(10000); // body-read timeout fires + await jest.advanceTimersByTimeAsync(2000); // backoff before the retry + const result = await promise; + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result?.accessToken).toBe('access-tok'); + }); + + it('retries a network error and resolves on the next success', async () => { + fetchMock + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce(mockResponse(200, TOKENS)); + + const promise = handleLoginCallback(CONFIG); + await jest.advanceTimersByTimeAsync(2000); + const result = await promise; + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result?.accessToken).toBe('access-tok'); + }); + + it('aborts a hung attempt at the timeout and retries', async () => { + // First attempt never responds; the per-attempt AbortController should fire at + // TOKEN_EXCHANGE_TIMEOUT_MS and reject it, which is treated as transient. + fetchMock.mockImplementationOnce( + (_url: string, init: RequestInit) => new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => { + reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })); + }); + }), + ); + fetchMock.mockResolvedValueOnce(mockResponse(200, TOKENS)); + + const promise = handleLoginCallback(CONFIG); + await jest.advanceTimersByTimeAsync(10000); // fire the attempt timeout -> abort + await jest.advanceTimersByTimeAsync(2000); // backoff before the retry + const result = await promise; + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result?.accessToken).toBe('access-tok'); + }); + + it('does not retry a 4xx', async () => { + fetchMock.mockResolvedValueOnce(mockResponse(400, { error: 'invalid_grant' })); + + await expect(handleLoginCallback(CONFIG)).rejects.toThrow('invalid_grant'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('gives up after exhausting retries', async () => { + fetchMock.mockResolvedValue(mockResponse(500, { error: 'boom' })); + + const promise = handleLoginCallback(CONFIG); + promise.catch(() => {}); // avoid an unhandled rejection while advancing timers + await jest.advanceTimersByTimeAsync(6000); // both backoffs, generous for jitter + + await expect(promise).rejects.toThrow('boom'); + expect(fetchMock).toHaveBeenCalledTimes(3); // initial + 2 retries + }); +}); diff --git a/packages/auth/src/login/standalone.ts b/packages/auth/src/login/standalone.ts index 8a23a3bb4b..01032bb428 100644 --- a/packages/auth/src/login/standalone.ts +++ b/packages/auth/src/login/standalone.ts @@ -430,6 +430,53 @@ async function buildAuthorizationUrl( // Token Exchange // ============================================================================ +// A transient failure on the token exchange (network error, timeout, CloudFront 5xx, +// rate limit) would otherwise drop an otherwise-valid login. The auth code is not +// consumed on a 5xx, so re-POSTing it is safe; a 4xx is permanent (bad/expired code). +const TOKEN_EXCHANGE_MAX_RETRIES = 2; +const TOKEN_EXCHANGE_RETRY_DELAY_MS = 1000; +// Bound each attempt so a stalled request becomes a retryable failure rather than an +// indefinite hang (this path has no other timeout around it). +const TOKEN_EXCHANGE_TIMEOUT_MS = 10000; + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +// Full jitter on the backoff so that when auth returns 5xx to many clients at once, +// their retries spread out instead of synchronising into a thundering herd. +function backoffWithJitter(attemptNumber: number): number { + const base = TOKEN_EXCHANGE_RETRY_DELAY_MS * attemptNumber; + return base * (0.5 + Math.random() * 0.5); +} + +// Outcome of a single token-exchange attempt: transient failures are retried, permanent +// ones (4xx) are thrown straight through. +type TokenAttemptOutcome = + | { kind: 'ok'; tokens: TokenResponse } + | { kind: 'transient'; error: Error; reason: string } + | { kind: 'permanent'; error: Error }; + +async function parseTokenErrorMessage(response: Response): Promise { + const errorText = await response.text(); + let errorMessage = `Token exchange failed with status ${response.status}`; + try { + const errorData = JSON.parse(errorText); + if (errorData.error_description) { + errorMessage = errorData.error_description; + } else if (errorData.error) { + errorMessage = errorData.error; + } + } catch { + if (errorText) { + errorMessage = errorText; + } + } + return errorMessage; +} + async function exchangeCodeForTokens( config: LoginConfig, code: string, @@ -438,8 +485,11 @@ async function exchangeCodeForTokens( ): Promise { const authDomain = getAuthDomain(config); const tokenUrl = `${authDomain}${TOKEN_ENDPOINT}`; - - const response = await fetch(tokenUrl, { + // Deliberately built once and reused across retry attempts: the body is a + // URLSearchParams, which fetch re-serialises on every call (it is not a one-shot + // consumable stream), so re-sending it is safe. Only the per-attempt AbortSignal + // differs, and that is merged in at the fetch call below. + const requestInit: RequestInit = { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -451,28 +501,65 @@ async function exchangeCodeForTokens( code, redirect_uri: redirectUri, }), - }); + }; + + const attempt = async (retriesLeft: number): Promise => { + const attemptNumber = TOKEN_EXCHANGE_MAX_RETRIES - retriesLeft + 1; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TOKEN_EXCHANGE_TIMEOUT_MS); + const startTime = Date.now(); + + // Classified as data rather than thrown, so a permanent failure isn't confused with a + // transient one by the catch below. + let outcome: TokenAttemptOutcome; - if (!response.ok) { - const errorText = await response.text(); - let errorMessage = `Token exchange failed with status ${response.status}`; try { - const errorData = JSON.parse(errorText); - if (errorData.error_description) { - errorMessage = errorData.error_description; - } else if (errorData.error) { - errorMessage = errorData.error; + const response = await fetch(tokenUrl, { ...requestInit, signal: controller.signal }); + + // Body reads stay inside the try so the abort above still covers them: fetch resolves + // once headers arrive, so a server that stalls the body would otherwise hang unbounded. + if (response.ok) { + outcome = { kind: 'ok', tokens: mapTokenResponseToResult(await response.json()) }; + } else { + const error = new Error(await parseTokenErrorMessage(response)); + // Only 5xx is transient; a 4xx (bad/expired/consumed code) is permanent. + outcome = response.status >= 500 + ? { kind: 'transient', error, reason: String(response.status) } + : { kind: 'permanent', error }; } - } catch { - if (errorText) { - errorMessage = errorText; + } catch (caught) { + // Network failure, or this attempt timing out mid-request or mid-body — all transient. + const error = caught instanceof Error ? caught : new Error('Token exchange network error'); + outcome = { kind: 'transient', error, reason: error.name === 'AbortError' ? 'timeout' : 'network' }; + } finally { + clearTimeout(timeoutId); + } + + if (outcome.kind === 'ok') { + if (attemptNumber > 1) { + track('passport', 'standaloneTokenExchangeRecovered', { attempt: attemptNumber }); } + return outcome.tokens; } - throw new Error(errorMessage); - } + if (outcome.kind === 'permanent') { + throw outcome.error; + } + + track('passport', 'standaloneTokenExchangeFailed', { + attempt: attemptNumber, + reason: outcome.reason, + willRetry: retriesLeft > 0, + timeToFailureMs: Date.now() - startTime, + }); + + if (retriesLeft > 0) { + await delay(backoffWithJitter(attemptNumber)); + return attempt(retriesLeft - 1); + } + throw outcome.error; + }; - const tokenData = await response.json(); - return mapTokenResponseToResult(tokenData); + return attempt(TOKEN_EXCHANGE_MAX_RETRIES); } // ============================================================================