diff --git a/mobile/src/lib/auth-helpers.test.ts b/mobile/src/lib/auth-helpers.test.ts new file mode 100644 index 0000000..1d92b98 --- /dev/null +++ b/mobile/src/lib/auth-helpers.test.ts @@ -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(); + }); +}); diff --git a/mobile/src/lib/auth-helpers.ts b/mobile/src/lib/auth-helpers.ts index 182bad9..d99875b 100644 --- a/mobile/src/lib/auth-helpers.ts +++ b/mobile/src/lib/auth-helpers.ts @@ -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); } diff --git a/mobile/src/store/auth-store.test.ts b/mobile/src/store/auth-store.test.ts index 7da8368..69d52e6 100644 --- a/mobile/src/store/auth-store.test.ts +++ b/mobile/src/store/auth-store.test.ts @@ -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(); }); }); diff --git a/mobile/src/store/auth-store.ts b/mobile/src/store/auth-store.ts index 6b37b4a..eb3bad1 100644 --- a/mobile/src/store/auth-store.ts +++ b/mobile/src/store/auth-store.ts @@ -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; + refreshSession: () => Promise; signIn: (email: string, password: string) => Promise; register: (email: string, password: string, displayName: string) => Promise; signOut: () => void; @@ -20,6 +68,7 @@ export const useAuthStore = create((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((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((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 }); }, }));