Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
135 changes: 134 additions & 1 deletion apps/mobile/src/lib/auth/auth-context.lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ type AuthContextValue = {
isSigningOut: boolean;
restoreFailed: boolean;
retryRestore: () => void;
signIn: (token: string) => Promise<void>;
signIn: (token: string, refreshToken?: string, expiresIn?: number) => Promise<void>;
signOut: (ended?: boolean) => Promise<void>;
};

Expand Down Expand Up @@ -1507,6 +1507,139 @@ describe('bootstrap and foreground race fencing', () => {
unmount();
});

it('regression: a refused refresh from a superseded epoch does not sign out the newer session', async () => {
const { getCtx, unmount } = await mountProvider();

// A session with a refresh token and an expiry inside the refresh margin,
// so a foreground event initiates a proactive refresh.
await act(async () => {
await getCtx().signIn('active-token', 'active-refresh', 3600);
});

const listeners = hoisted.appState.addEventListener.mock.calls;
const eventListener = listeners.at(-1)?.[1];

// Serve the foreground's expiry read and the refresh's refresh-token read.
// eslint-disable-next-line require-await -- mock returning a resolved promise
hoisted.secureStore.getItemAsync.mockImplementation(async (key: string) => {
if (key === 'token-expires-at') {
return String(Date.now() + 60_000);
}
if (key === 'refresh-token') {
return 'active-refresh';
}
return null;
});

const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(Response.json({ error: 'INVALID_REFRESH_TOKEN' }, { status: 401 }));

// Hold the terminal clear open so the epoch can move inside it: this is the
// window where the refresh still returns refused for the session that
// owned it.
const { promise: clearGate, resolve: releaseClear } = Promise.withResolvers<undefined>();
hoisted.secureStore.deleteItemAsync.mockImplementationOnce(async () => {
await clearGate;
});

await act(async () => {
eventListener?.('active');
await Promise.resolve();
});

// Wait until the clear is in flight, then move the epoch: a newer session
// now owns the tree while the old refresh is still inside its clear.
const authEpoch = await import('@/lib/auth/auth-epoch');
let flushes = 0;
while (hoisted.secureStore.deleteItemAsync.mock.calls.length === 0 && flushes < 50) {
flushes += 1;
// eslint-disable-next-line no-await-in-loop -- sequential flush until the clear is in flight
await act(async () => {
await new Promise<void>(resolve => {
void setTimeout(resolve, 0);
});
});
}
expect(hoisted.secureStore.deleteItemAsync).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalled();

authEpoch.bumpAuthEpoch();
releaseClear(undefined);

await act(async () => {
await new Promise<void>(resolve => {
void setTimeout(resolve, 0);
});
});

// The stale refusal must not tear down the newer session.
expect(getCtx().sessionEnded).toBe(false);
expect(getCtx().token).toBe('active-token');

fetchSpy.mockRestore();
unmount();
});

it('regression: the refusal-triggered sign-out cleanup still authenticates with the owner token', async () => {
const { getCtx, unmount } = await mountProvider();

// A session with a refresh token and an expiry inside the refresh margin,
// so a foreground event initiates a proactive refresh.
await act(async () => {
await getCtx().signIn('active-token', 'active-refresh', 3600);
});

const listeners = hoisted.appState.addEventListener.mock.calls;
const eventListener = listeners.at(-1)?.[1];

// Serve the foreground's expiry read and the refresh's refresh-token read.
// eslint-disable-next-line require-await -- mock returning a resolved promise
hoisted.secureStore.getItemAsync.mockImplementation(async (key: string) => {
if (key === 'token-expires-at') {
return String(Date.now() + 60_000);
}
if (key === 'refresh-token') {
return 'active-refresh';
}
return null;
});

const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(Response.json({ error: 'INVALID_REFRESH_TOKEN' }, { status: 401 }));

// The 401 clear runs before the refusal-triggered sign-out. The owner must
// still serve the token to runLogoutCleanup's revoke/unregister, which run
// before the epoch bump and read the Authorization header through
// `getAuthTokenForRequest`.
const tokens: typeof TokenOwnerModule = await import('./token-owner');
let cleanupToken: string | null | 'unset' = 'unset';
logoutCleanupMock.runLogoutCleanup.mockImplementationOnce(async () => {
cleanupToken = await tokens.getAuthTokenForRequest();
});

await act(async () => {
eventListener?.('active');
await Promise.resolve();
});

await vi.waitFor(() => {
expect(logoutCleanupMock.runLogoutCleanup).toHaveBeenCalled();
});
await act(async () => {
await new Promise<void>(resolve => {
void setTimeout(resolve, 0);
});
});

// The remote cleanup authenticated with the session's own access token.
expect(cleanupToken).toBe('active-token');

fetchSpy.mockRestore();
unmount();
});

it('regression: a signed-out launch re-runs the OS search clear the teardown cannot await', async () => {
// The beforeEach leaves every read null: the launch positively restored no
// session, which is the retry for a teardown-time clear that failed or a
Expand Down
8 changes: 6 additions & 2 deletions apps/mobile/src/lib/auth/auth-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ vi.mock('react-native', () => ({

import { exchangeLegacyToken } from '@/lib/auth/exchange-legacy-token';
import { performRefresh, persistSignInCredentialsAtEpoch } from '@/lib/auth/credentials';
import { bumpAuthEpoch } from '@/lib/auth/auth-epoch';
import { bumpAuthEpoch, currentAuthEpoch } from '@/lib/auth/auth-epoch';
import {
AUTH_TOKEN_KEY,
LEGACY_EXCHANGE_DONE_KEY,
Expand Down Expand Up @@ -168,7 +168,11 @@ describe('performRefresh', () => {
);

const outcome = await performRefresh();
expect(outcome).toEqual({ ok: false, refused: true });
expect(outcome).toEqual({
ok: false,
refused: true,
sessionVersion: currentAuthEpoch(),
});
});

it('returns transient when the server responds with a non-401 error', async () => {
Expand Down
32 changes: 28 additions & 4 deletions apps/mobile/src/lib/auth/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,16 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) {
return;
}

if (!outcome.ok && outcome.refused && !isSignedOutReference.current) {
// A refusal belongs to the session that owned the refresh. The epoch can
// move while the refresh awaited — or this call can join an in-flight
// refresh from an older session — so re-check before tearing down: a
// newer session must not be signed out by an older refusal.
if (
!outcome.ok &&
outcome.refused &&
isCurrentAuthEpoch(outcome.sessionVersion) &&
!isSignedOutReference.current
) {
await signOut(true, 'session_ended');
}
};
Expand Down Expand Up @@ -661,9 +670,24 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) {

if (outcome.ok && isCurrentAuthEpoch(outcome.sessionVersion)) {
setToken(outcome.token);
return;
}
// A refused refresh means the stored pair is gone. The proactive path
// used to keep the dead token and wait for "the next real 401", which
// is exactly the retry the refresh refused: every foreground asked
// again. Stop the loop here and send the person to sign in. A
// transient failure leaves the session alone. The refusal is scoped
// to the session that owned it: the epoch can move while the clear
// inside the refresh awaits, so a refusal from a superseded session
// must not sign out the newer one.
if (
!outcome.ok &&
outcome.refused &&
isCurrentAuthEpoch(outcome.sessionVersion) &&
!isSignedOutReference.current
) {
await signOut(true, 'session_ended');
}
// Transient or refused: do not sign out — the user did not trigger
// an authenticated request. Let the next real 401 handle it.
} catch {
// A rejected expiry read (or refresh) must not escape as an
// unhandled rejection. Return silently, exactly as the null-expiry
Expand All @@ -675,7 +699,7 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) {
return () => {
subscription.remove();
};
}, [token]);
}, [signOut, token]);

const value = useMemo<AuthContextValue>(
() => ({
Expand Down
46 changes: 46 additions & 0 deletions apps/mobile/src/lib/auth/auth-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { AUTH_REQUEST_TIMEOUT_MS, postAuth } from '@/lib/auth/auth-fetch';
import { resetAuthTerminalReports } from '@/lib/auth/auth-response-class';

// Mock @/lib/config to avoid pulling in react-native at module import time.
vi.mock('@/lib/config', () => ({
Expand All @@ -24,8 +25,10 @@ vi.mock('expo-application', () => ({

describe('postAuth', () => {
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
vi.useRealTimers();
resetAuthTerminalReports();
});

it('forwards ssoOrganizationId on a non-ok SSO_ERROR response', async () => {
Expand All @@ -39,6 +42,8 @@ describe('postAuth', () => {
ok: false,
errorCode: 'SSO_ERROR',
ssoOrganizationId: 'org_1',
httpStatus: 400,
terminal: true,
});
});

Expand All @@ -53,6 +58,44 @@ describe('postAuth', () => {
ok: false,
errorCode: 'BLOCKED',
ssoOrganizationId: undefined,
httpStatus: 400,
terminal: true,
});
});

it('classifies a 401 on a credential route as terminal', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
Response.json({ error: 'INVALID_CODE' }, { status: 401 })
);

const result = await postAuth('/api/auth/native/token', { provider: 'email' });

expect(result).toEqual({
ok: false,
errorCode: 'INVALID_CODE',
ssoOrganizationId: undefined,
httpStatus: 401,
terminal: true,
});
});

it('keeps a 429 retryable and carries the server Retry-After', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
Response.json(
{ error: 'TOO_MANY_ATTEMPTS' },
{ status: 429, headers: { 'retry-after': '7' } }
)
);

const result = await postAuth('/api/auth/native/otp', { email: 'user@example.com' });

expect(result).toEqual({
ok: false,
errorCode: 'TOO_MANY_ATTEMPTS',
ssoOrganizationId: undefined,
httpStatus: 429,
terminal: false,
retryAfterMs: 7000,
});
});

Expand Down Expand Up @@ -80,6 +123,7 @@ describe('postAuth', () => {
ok: false,
errorCode: 'TIMEOUT',
ssoOrganizationId: undefined,
terminal: false,
});
expect(signals).toHaveLength(1);
expect(signals[0]?.aborted).toBe(true);
Expand Down Expand Up @@ -108,6 +152,7 @@ describe('postAuth', () => {
ok: false,
errorCode: 'TIMEOUT',
ssoOrganizationId: undefined,
terminal: false,
});
});

Expand All @@ -120,6 +165,7 @@ describe('postAuth', () => {
ok: false,
errorCode: undefined,
ssoOrganizationId: undefined,
terminal: false,
});
});
});
Loading
Loading