Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/ios/App/App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,11 @@
<true/>
<key>VellumAssociatedDomain</key>
<string>$(ASSOCIATED_DOMAIN)</string>
<key>WKAppBoundDomains</key>
<array>
<string>www.vellum.ai</string>
<string>dev-assistant.vellum.ai</string>
<string>staging-assistant.vellum.ai</string>
</array>
</dict>
</plist>
5 changes: 5 additions & 0 deletions apps/web/capacitor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ const config: CapacitorConfig = {
contentInset: "never",
scheme: SCHEME_NAMES[env] ?? "App",
},
plugins: {
CapacitorCookies: {
enabled: true,
},
},
};

export default config;
92 changes: 92 additions & 0 deletions apps/web/src/stores/auth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } };
}
Expand All @@ -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,
}));
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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();
});
});
25 changes: 25 additions & 0 deletions apps/web/src/stores/auth-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -107,6 +110,27 @@ const useAuthStoreBase = create<AuthStore>()((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 });
},
Expand All @@ -132,6 +156,7 @@ const useAuthStoreBase = create<AuthStore>()((set) => ({
try {
await allauthLogout();
} finally {
void deleteBiometricToken();
syncUserScopedState(null);
set({ isLoggedIn: false, user: null });
broadcastAuthChange();
Expand Down