fix(mobile): harden auth session bootstrap

This commit is contained in:
Saravana Achu Mac 2026-05-05 12:50:57 -07:00
parent e1cc7feb5b
commit 3d02deb14c
4 changed files with 233 additions and 10 deletions

View File

@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { PRODUCT_ID } from '../api/config';
import { mmkvStorage } from '../store/mmkv-storage';
import { AUTH_ACCESS_TOKEN_KEY, getAccessToken } from './auth-helpers';
describe('mobile auth helpers', () => {
beforeEach(() => {
mmkvStorage.removeItem(`${PRODUCT_ID}_access_token`);
mmkvStorage.removeItem(`${PRODUCT_ID}-auth-token`);
});
it('reads the shared auth-client access token key', () => {
mmkvStorage.setItem(AUTH_ACCESS_TOKEN_KEY, 'platform-token');
expect(getAccessToken()).toBe('platform-token');
});
it('does not read the legacy mobile-only token key', () => {
mmkvStorage.setItem(`${PRODUCT_ID}_access_token`, 'legacy-token');
expect(getAccessToken()).toBeNull();
});
});

View File

@ -1,6 +1,8 @@
import { PRODUCT_ID } from '../api/config';
import { mmkvStorage } from '../store/mmkv-storage';
export const AUTH_ACCESS_TOKEN_KEY = `${PRODUCT_ID}-auth-token`;
export function getAccessToken(): string | null {
return mmkvStorage.getItem(`${PRODUCT_ID}_access_token`);
return mmkvStorage.getItem(AUTH_ACCESS_TOKEN_KEY);
}

View File

@ -1,10 +1,13 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { PRODUCT_ID } from '../api/config';
import { mmkvStorage } from './mmkv-storage';
const isAuthenticatedMock = vi.fn();
const getMeMock = vi.fn();
const loginMock = vi.fn();
const registerMock = vi.fn();
const clearTokensMock = vi.fn();
const refreshAccessTokenMock = vi.fn();
vi.mock('../api/auth', () => ({
getAuthClient: () => ({
@ -12,6 +15,7 @@ vi.mock('../api/auth', () => ({
getMe: getMeMock,
login: loginMock,
register: registerMock,
refreshAccessToken: refreshAccessTokenMock,
clearTokens: clearTokensMock,
getAccessToken: vi.fn(() => null),
}),
@ -20,6 +24,7 @@ vi.mock('../api/auth', () => ({
import { useAuthStore } from './auth-store';
function resetStore() {
mmkvStorage.removeItem(`${PRODUCT_ID}-auth-email`);
useAuthStore.setState({
hasBootstrapped: false,
isAuthenticated: false,
@ -41,67 +46,143 @@ describe('useAuthStore', () => {
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAuthStore.getState().email).toBe('test@example.com');
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBe('test@example.com');
});
it('bootstrap sets unauthenticated when no token', async () => {
isAuthenticatedMock.mockReturnValue(false);
mmkvStorage.setItem(`${PRODUCT_ID}-auth-email`, 'cached@example.com');
await useAuthStore.getState().bootstrap();
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(useAuthStore.getState().email).toBeNull();
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBeNull();
});
it('bootstrap clears tokens on getMe failure', async () => {
it('bootstrap preserves a valid local session during offline startup', async () => {
isAuthenticatedMock.mockReturnValue(true);
getMeMock.mockRejectedValueOnce(new Error('expired'));
mmkvStorage.setItem(`${PRODUCT_ID}-auth-email`, 'cached@example.com');
getMeMock.mockRejectedValueOnce(new Error('Network request failed'));
await useAuthStore.getState().bootstrap();
expect(clearTokensMock).not.toHaveBeenCalled();
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAuthStore.getState().email).toBe('cached@example.com');
});
it('bootstrap refreshes an expired token before clearing the session', async () => {
isAuthenticatedMock.mockReturnValue(true);
getMeMock.mockRejectedValueOnce(new Error('HTTP 401'));
refreshAccessTokenMock.mockResolvedValueOnce(true);
getMeMock.mockResolvedValueOnce({ email: 'fresh@example.com' });
await useAuthStore.getState().bootstrap();
expect(refreshAccessTokenMock).toHaveBeenCalled();
expect(clearTokensMock).not.toHaveBeenCalled();
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAuthStore.getState().email).toBe('fresh@example.com');
});
it('bootstrap clears tokens on non-refreshable getMe failure', async () => {
isAuthenticatedMock.mockReturnValue(true);
mmkvStorage.setItem(`${PRODUCT_ID}-auth-email`, 'cached@example.com');
getMeMock.mockRejectedValueOnce(new Error('invalid account'));
await useAuthStore.getState().bootstrap();
expect(clearTokensMock).toHaveBeenCalled();
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBeNull();
});
it('signIn sets authenticated on success', async () => {
loginMock.mockResolvedValueOnce(undefined);
loginMock.mockResolvedValueOnce({
accessToken: 'access',
refreshToken: 'refresh',
user: { email: 'test@example.com' },
});
const ok = await useAuthStore.getState().signIn('test@example.com', 'pass');
expect(ok).toBe(true);
expect(loginMock).toHaveBeenCalledWith('test@example.com', 'pass');
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAuthStore.getState().email).toBe('test@example.com');
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBe('test@example.com');
});
it('signIn returns false when the platform requires MFA', async () => {
loginMock.mockResolvedValueOnce({ mfaRequired: true, mfaChallenge: 'challenge', methods: ['totp'] });
const ok = await useAuthStore.getState().signIn('test@example.com', 'pass');
expect(ok).toBe(false);
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(clearTokensMock).toHaveBeenCalled();
});
it('signIn returns false on failure', async () => {
loginMock.mockRejectedValueOnce(new Error('bad credentials'));
mmkvStorage.setItem(`${PRODUCT_ID}-auth-email`, 'old@example.com');
const ok = await useAuthStore.getState().signIn('bad@example.com', 'wrong');
expect(ok).toBe(false);
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(clearTokensMock).toHaveBeenCalled();
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBeNull();
});
it('register sets authenticated on success', async () => {
registerMock.mockResolvedValueOnce(undefined);
registerMock.mockResolvedValueOnce({
accessToken: 'access',
refreshToken: 'refresh',
user: { email: 'new@example.com' },
});
const ok = await useAuthStore.getState().register('new@example.com', 'pass', 'New User');
expect(ok).toBe(true);
expect(registerMock).toHaveBeenCalledWith('new@example.com', 'pass', 'New User');
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAuthStore.getState().email).toBe('new@example.com');
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBe('new@example.com');
});
it('register returns false on failure', async () => {
registerMock.mockRejectedValueOnce(new Error('register failed'));
mmkvStorage.setItem(`${PRODUCT_ID}-auth-email`, 'old@example.com');
const ok = await useAuthStore.getState().register('bad@example.com', 'wrong', 'Bad');
expect(ok).toBe(false);
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(clearTokensMock).toHaveBeenCalled();
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBeNull();
});
it('refreshSession keeps authenticated state on refresh success', async () => {
isAuthenticatedMock.mockReturnValue(true);
refreshAccessTokenMock.mockResolvedValueOnce(true);
getMeMock.mockResolvedValueOnce({ email: 'fresh@example.com' });
const ok = await useAuthStore.getState().refreshSession();
expect(ok).toBe(true);
expect(refreshAccessTokenMock).toHaveBeenCalled();
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAuthStore.getState().email).toBe('fresh@example.com');
});
it('refreshSession clears state when refresh fails', async () => {
isAuthenticatedMock.mockReturnValue(true);
refreshAccessTokenMock.mockResolvedValueOnce(false);
mmkvStorage.setItem(`${PRODUCT_ID}-auth-email`, 'old@example.com');
const ok = await useAuthStore.getState().refreshSession();
expect(ok).toBe(false);
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBeNull();
});
it('signOut clears state', () => {
mmkvStorage.setItem(`${PRODUCT_ID}-auth-email`, 'test@example.com');
useAuthStore.setState({ hasBootstrapped: true, isAuthenticated: true, email: 'test@example.com' });
useAuthStore.getState().signOut();
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(useAuthStore.getState().email).toBeNull();
expect(clearTokensMock).toHaveBeenCalled();
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBeNull();
});
});

View File

@ -1,5 +1,52 @@
import { create } from 'zustand';
import { getAuthClient } from '../api/auth';
import { PRODUCT_ID } from '../api/config';
import { mmkvStorage } from './mmkv-storage';
const AUTH_EMAIL_CACHE_KEY = `${PRODUCT_ID}-auth-email`;
function getCachedEmail(): string | null {
return mmkvStorage.getItem(AUTH_EMAIL_CACHE_KEY);
}
function setCachedEmail(email: string): void {
mmkvStorage.setItem(AUTH_EMAIL_CACHE_KEY, email);
}
function clearCachedEmail(): void {
mmkvStorage.removeItem(AUTH_EMAIL_CACHE_KEY);
}
function isOfflineStartupError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const message = error.message.toLowerCase();
return (
message.includes('network') ||
message.includes('failed to fetch') ||
message.includes('fetch failed') ||
message.includes('offline') ||
message.includes('timeout') ||
message.includes('abort')
);
}
function isRefreshableAuthError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const message = error.message.toLowerCase();
return (
message.includes('http 401') ||
message.includes('http 403') ||
message.includes('unauthorized') ||
message.includes('forbidden') ||
message.includes('expired')
);
}
export type AuthState = {
hasBootstrapped: boolean;
@ -7,6 +54,7 @@ export type AuthState = {
isLoading: boolean;
email: string | null;
bootstrap: () => Promise<void>;
refreshSession: () => Promise<boolean>;
signIn: (email: string, password: string) => Promise<boolean>;
register: (email: string, password: string, displayName: string) => Promise<boolean>;
signOut: () => void;
@ -20,6 +68,7 @@ export const useAuthStore = create<AuthState>((set) => ({
async bootstrap() {
const client = getAuthClient();
if (!client.isAuthenticated()) {
clearCachedEmail();
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
return;
}
@ -28,21 +77,86 @@ export const useAuthStore = create<AuthState>((set) => ({
try {
const me = await client.getMe();
setCachedEmail(me.email);
set({ hasBootstrapped: true, isAuthenticated: true, email: me.email, isLoading: false });
} catch {
} catch (error) {
if (isOfflineStartupError(error) && client.isAuthenticated()) {
set({
hasBootstrapped: true,
isAuthenticated: true,
email: getCachedEmail(),
isLoading: false,
});
return;
}
if (isRefreshableAuthError(error) && (await client.refreshAccessToken())) {
try {
const me = await client.getMe();
setCachedEmail(me.email);
set({ hasBootstrapped: true, isAuthenticated: true, email: me.email, isLoading: false });
return;
} catch (refreshError) {
if (isOfflineStartupError(refreshError) && client.isAuthenticated()) {
set({
hasBootstrapped: true,
isAuthenticated: true,
email: getCachedEmail(),
isLoading: false,
});
return;
}
}
}
client.clearTokens();
clearCachedEmail();
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
}
},
async refreshSession() {
const client = getAuthClient();
if (!client.isAuthenticated()) {
clearCachedEmail();
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
return false;
}
set({ isLoading: true });
const refreshed = await client.refreshAccessToken();
if (!refreshed) {
clearCachedEmail();
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
return false;
}
try {
const me = await client.getMe();
setCachedEmail(me.email);
set({ hasBootstrapped: true, isAuthenticated: true, email: me.email, isLoading: false });
} catch {
set({ hasBootstrapped: true, isAuthenticated: true, email: getCachedEmail(), isLoading: false });
}
return true;
},
async signIn(email: string, password: string) {
set({ isLoading: true });
try {
await getAuthClient().login(email, password);
set({ hasBootstrapped: true, isAuthenticated: true, email, isLoading: false });
const result = await getAuthClient().login(email, password);
if ('mfaRequired' in result) {
getAuthClient().clearTokens();
clearCachedEmail();
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
return false;
}
setCachedEmail(result.user.email);
set({ hasBootstrapped: true, isAuthenticated: true, email: result.user.email, isLoading: false });
return true;
} catch {
getAuthClient().clearTokens();
clearCachedEmail();
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
return false;
}
@ -51,17 +165,20 @@ export const useAuthStore = create<AuthState>((set) => ({
set({ isLoading: true });
try {
await getAuthClient().register(email, password, displayName);
set({ hasBootstrapped: true, isAuthenticated: true, email, isLoading: false });
const result = await getAuthClient().register(email, password, displayName);
setCachedEmail(result.user.email);
set({ hasBootstrapped: true, isAuthenticated: true, email: result.user.email, isLoading: false });
return true;
} catch {
getAuthClient().clearTokens();
clearCachedEmail();
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
return false;
}
},
signOut() {
getAuthClient().clearTokens();
clearCachedEmail();
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
},
}));