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
81 changes: 81 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,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" } };
}
Expand All @@ -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,
}));
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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();
});
});
23 changes: 23 additions & 0 deletions apps/web/src/stores/auth-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -107,6 +109,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()) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict biometric fallback to auth-cookie failures

The biometric branch runs for every initial session miss, including transient network errors or server 5xx responses from getSession(), not just missing/expired auth cookies. On native devices with biometrics enabled, this can trigger unnecessary Face ID/Touch ID prompts during outages or offline resumes, even though recovery cannot succeed in those conditions.

Useful? React with 👍 / 👎.

try {
const token = await retrieveBiometricToken();
if (token) {
installSessionCookies(token);
const retryResult = await getSession();
if (retryResult.ok && retryResult.data.user) {
Comment on lines +119 to +121
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wait for cookie flush before probing recovered session

After writing the recovered token with installSessionCookies(token), this immediately calls getSession() once. In WKWebView, cookie writes are asynchronously flushed to the HTTP cookie store, so the first probe can still be unauthenticated even with a valid token; this causes initSession() to incorrectly fall through to logged-out state and makes biometric recovery flaky on iOS cold starts.

Useful? React with 👍 / 👎.

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 Down