diff --git a/apps/web/src/stores/auth-store.test.ts b/apps/web/src/stores/auth-store.test.ts index 95159cff617..4ac66c5c29e 100644 --- a/apps/web/src/stores/auth-store.test.ts +++ b/apps/web/src/stores/auth-store.test.ts @@ -7,12 +7,18 @@ type MockSessionUser = { }; let sessionUser: MockSessionUser | null = null; +let getSessionCallCount = 0; +let getSessionFailFirstCall = false; const syncOnboardingUserMock = mock((_userId: string | null) => {}); const clearOrganizationMock = mock(() => {}); const logoutMock = mock(async () => {}); mock.module("@/lib/auth/allauth-client.js", () => ({ getSession: async () => { + getSessionCallCount++; + if (getSessionFailFirstCall && getSessionCallCount === 1) { + return { ok: false, status: 401, error: { detail: "Unauthorized" } }; + } if (!sessionUser) { return { ok: false, status: 401, error: { detail: "Unauthorized" } }; } @@ -21,6 +27,22 @@ mock.module("@/lib/auth/allauth-client.js", () => ({ logout: logoutMock, })); +let mockIsNativePlatform = false; +let mockIsBiometricEnabled = false; +let mockBiometricToken: string | null = null; +const installSessionCookiesMock = mock((_token: string) => {}); +const retrieveBiometricTokenMock = mock(async () => mockBiometricToken); + +mock.module("@/runtime/native-auth.js", () => ({ + isNativePlatform: () => mockIsNativePlatform, + installSessionCookies: installSessionCookiesMock, +})); + +mock.module("@/runtime/native-biometric.js", () => ({ + isBiometricEnabled: () => mockIsBiometricEnabled, + retrieveBiometricToken: retrieveBiometricTokenMock, +})); + mock.module("@/domains/onboarding/prefs.js", () => ({ syncOnboardingUser: syncOnboardingUserMock, })); @@ -49,9 +71,16 @@ function resetAuthStore(): void { beforeEach(() => { sessionUser = null; + getSessionCallCount = 0; + getSessionFailFirstCall = false; + mockIsNativePlatform = false; + mockIsBiometricEnabled = false; + mockBiometricToken = null; syncOnboardingUserMock.mockClear(); clearOrganizationMock.mockClear(); logoutMock.mockClear(); + installSessionCookiesMock.mockClear(); + retrieveBiometricTokenMock.mockClear(); resetAuthStore(); }); @@ -83,3 +112,55 @@ describe("auth store onboarding flag reconciliation", () => { expect(useAuthStore.getState().isLoggedIn).toBe(false); }); }); + +describe("biometric session recovery", () => { + test("initSession falls through to biometric recovery on native when session probe fails", async () => { + mockIsNativePlatform = true; + mockIsBiometricEnabled = true; + mockBiometricToken = "recovered-session-token"; + sessionUser = { id: "user-1", email: "user@example.com" }; + getSessionFailFirstCall = true; + + await useAuthStore.getState().initSession(); + + expect(installSessionCookiesMock).toHaveBeenCalledWith( + "recovered-session-token", + ); + expect(useAuthStore.getState().isLoggedIn).toBe(true); + expect(useAuthStore.getState().user?.id).toBe("user-1"); + }); + + test("initSession skips biometric recovery on web", async () => { + mockIsNativePlatform = false; + sessionUser = null; + + await useAuthStore.getState().initSession(); + + expect(retrieveBiometricTokenMock).not.toHaveBeenCalled(); + expect(useAuthStore.getState().isLoggedIn).toBe(false); + }); + + test("initSession skips biometric recovery when biometrics disabled", async () => { + mockIsNativePlatform = true; + mockIsBiometricEnabled = false; + sessionUser = null; + + await useAuthStore.getState().initSession(); + + expect(retrieveBiometricTokenMock).not.toHaveBeenCalled(); + expect(useAuthStore.getState().isLoggedIn).toBe(false); + }); + + test("initSession falls through to unauthenticated when biometric token is expired", async () => { + mockIsNativePlatform = true; + mockIsBiometricEnabled = true; + mockBiometricToken = "expired-token"; + sessionUser = null; + + await useAuthStore.getState().initSession(); + + expect(installSessionCookiesMock).toHaveBeenCalledWith("expired-token"); + expect(useAuthStore.getState().isLoggedIn).toBe(false); + expect(useAuthStore.getState().user).toBeNull(); + }); +}); diff --git a/apps/web/src/stores/auth-store.ts b/apps/web/src/stores/auth-store.ts index 84728433856..14088f50fc9 100644 --- a/apps/web/src/stores/auth-store.ts +++ b/apps/web/src/stores/auth-store.ts @@ -22,6 +22,8 @@ import { import { syncOnboardingUser } from "@/domains/onboarding/prefs.js"; import { clearOrganization } from "@/stores/organization-store.js"; import { useEventBusStore } from "@/stores/event-bus-store.js"; +import { isNativePlatform, installSessionCookies } from "@/runtime/native-auth.js"; +import { isBiometricEnabled, retrieveBiometricToken } from "@/runtime/native-biometric.js"; export interface AuthUser { id: string | null; @@ -107,6 +109,27 @@ const useAuthStoreBase = create()((set) => ({ } catch (err) { console.error("auth.initSession failed", err); } + + // Biometric recovery: on iOS, the session cookie may have been lost + // when WKWebView was killed. Try to restore from Keychain via Face ID. + if (isNativePlatform() && isBiometricEnabled()) { + try { + const token = await retrieveBiometricToken(); + if (token) { + installSessionCookies(token); + const retryResult = await getSession(); + if (retryResult.ok && retryResult.data.user) { + const user = toAuthUser(retryResult.data.user); + syncUserScopedState(user?.id ?? null); + set({ isLoggedIn: true, isLoading: false, user }); + return; + } + } + } catch (err) { + console.warn("auth.initSession biometric recovery failed", err); + } + } + syncUserScopedState(null); set({ isLoggedIn: false, isLoading: false, user: null }); },