Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions packages/auth/src/login/standalone.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response>((_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
});
});
123 changes: 105 additions & 18 deletions packages/auth/src/login/standalone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<string> {
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,
Expand All @@ -438,8 +485,11 @@ async function exchangeCodeForTokens(
): Promise<TokenResponse> {
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',
Expand All @@ -451,28 +501,65 @@ async function exchangeCodeForTokens(
code,
redirect_uri: redirectUri,
}),
});
};

const attempt = async (retriesLeft: number): Promise<TokenResponse> => {
Comment thread
lfportal marked this conversation as resolved.
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);
}

// ============================================================================
Expand Down
Loading