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();