diff --git a/apps/ios/App/App/Info.plist b/apps/ios/App/App/Info.plist index 43f1c28862c..27ae9833f9e 100644 --- a/apps/ios/App/App/Info.plist +++ b/apps/ios/App/App/Info.plist @@ -77,5 +77,11 @@ VellumAssociatedDomain $(ASSOCIATED_DOMAIN) + WKAppBoundDomains + + www.vellum.ai + dev-assistant.vellum.ai + staging-assistant.vellum.ai + diff --git a/apps/web/capacitor.config.ts b/apps/web/capacitor.config.ts index a5d091b8798..2c40f115853 100644 --- a/apps/web/capacitor.config.ts +++ b/apps/web/capacitor.config.ts @@ -59,6 +59,11 @@ const config: CapacitorConfig = { contentInset: "never", scheme: SCHEME_NAMES[env] ?? "App", }, + plugins: { + CapacitorCookies: { + enabled: true, + }, + }, }; export default config; diff --git a/apps/web/src/stores/auth-store.test.ts b/apps/web/src/stores/auth-store.test.ts index 95159cff617..cd6716c2f37 100644 --- a/apps/web/src/stores/auth-store.test.ts +++ b/apps/web/src/stores/auth-store.test.ts @@ -7,12 +7,25 @@ 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 () => {}); +const deleteBiometricTokenMock = mock(async () => {}); + +let mockIsNativePlatform = false; +let mockIsBiometricEnabled = false; +let mockBiometricToken: string | null = null; +const installSessionCookiesMock = mock((_token: string) => {}); +const retrieveBiometricTokenMock = mock(async () => mockBiometricToken); 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 +34,17 @@ mock.module("@/lib/auth/allauth-client.js", () => ({ logout: logoutMock, })); +mock.module("@/runtime/native-auth.js", () => ({ + isNativePlatform: () => mockIsNativePlatform, + installSessionCookies: installSessionCookiesMock, +})); + +mock.module("@/runtime/native-biometric.js", () => ({ + deleteBiometricToken: deleteBiometricTokenMock, + isBiometricEnabled: () => mockIsBiometricEnabled, + retrieveBiometricToken: retrieveBiometricTokenMock, +})); + mock.module("@/domains/onboarding/prefs.js", () => ({ syncOnboardingUser: syncOnboardingUserMock, })); @@ -49,9 +73,17 @@ function resetAuthStore(): void { beforeEach(() => { sessionUser = null; + getSessionCallCount = 0; + getSessionFailFirstCall = false; + mockIsNativePlatform = false; + mockIsBiometricEnabled = false; + mockBiometricToken = null; syncOnboardingUserMock.mockClear(); clearOrganizationMock.mockClear(); logoutMock.mockClear(); + deleteBiometricTokenMock.mockClear(); + installSessionCookiesMock.mockClear(); + retrieveBiometricTokenMock.mockClear(); resetAuthStore(); }); @@ -83,3 +115,63 @@ describe("auth store onboarding flag reconciliation", () => { expect(useAuthStore.getState().isLoggedIn).toBe(false); }); }); + +describe("biometric cleanup on logout", () => { + test("logout clears biometric token", async () => { + await useAuthStore.getState().logout(); + + expect(deleteBiometricTokenMock).toHaveBeenCalled(); + }); +}); + +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..306d242d99c 100644 --- a/apps/web/src/stores/auth-store.ts +++ b/apps/web/src/stores/auth-store.ts @@ -19,9 +19,12 @@ import { getSession, logout as allauthLogout, } from "@/lib/auth/allauth-client.js"; +import { deleteBiometricToken } from "@/runtime/native-biometric.js"; 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 +110,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 }); }, @@ -132,6 +156,7 @@ const useAuthStoreBase = create()((set) => ({ try { await allauthLogout(); } finally { + void deleteBiometricToken(); syncUserScopedState(null); set({ isLoggedIn: false, user: null }); broadcastAuthChange();