fix(mobile): harden auth session bootstrap
This commit is contained in:
parent
e1cc7feb5b
commit
3d02deb14c
23
mobile/src/lib/auth-helpers.test.ts
Normal file
23
mobile/src/lib/auth-helpers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user