diff --git a/apps/ios/App/App/NativeAuthPlugin.swift b/apps/ios/App/App/NativeAuthPlugin.swift index 4bdb8e6ac76..bb396a2e39f 100644 --- a/apps/ios/App/App/NativeAuthPlugin.swift +++ b/apps/ios/App/App/NativeAuthPlugin.swift @@ -257,7 +257,20 @@ public class NativeAuthPlugin: CAPPlugin, CAPBridgedPlugin { call.reject("Code exchange returned invalid response") return } - call.resolve(["sessionToken": sessionToken]) + // The exchange POST writes to `HTTPCookieStorage.shared`, + // not WKWebView's jar; plant the cookie before resolving + // so the next JS navigation actually carries it. + guard let webView = self?.webView else { + call.reject("WebView unavailable for cookie install") + return + } + SessionCookieInstaller.install( + token: sessionToken, + server: baseURL, + into: webView + ) { + call.resolve(["sessionToken": sessionToken]) + } }.resume() } diff --git a/apps/ios/App/App/NativeBiometricPlugin.swift b/apps/ios/App/App/NativeBiometricPlugin.swift index 7137ee2cfa6..040ed39f60e 100644 --- a/apps/ios/App/App/NativeBiometricPlugin.swift +++ b/apps/ios/App/App/NativeBiometricPlugin.swift @@ -41,6 +41,8 @@ public class NativeBiometricPlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "storeToken", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "retrieveToken", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "deleteToken", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "installSessionCookie", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "readSessionCookie", returnType: CAPPluginReturnPromise), ] private static let keychainService = "ai.vocify-inc.vellum-assistant-ios.biometric-auth" @@ -234,4 +236,73 @@ public class NativeBiometricPlugin: CAPPlugin, CAPBridgedPlugin { call.reject("Keychain delete failed with status: \(status)") } } + + // MARK: - installSessionCookie + + /// Plant a biometric-recovered session token into WKWebView's cookie + /// jar so subsequent requests carry the Django session. Used after + /// `retrieveToken` returns a token from the Keychain on cold launch. + /// + /// Expects `{ token: string, serverURL: string }` where `serverURL` + /// is a full origin (e.g. `https://dev-assistant.vellum.ai`). + @objc public func installSessionCookie(_ call: CAPPluginCall) { + guard let token = call.getString("token"), !token.isEmpty else { + call.reject("Missing required option: token") + return + } + guard let serverURLString = call.getString("serverURL"), + let serverURL = URL(string: serverURLString), + serverURL.host != nil else { + call.reject("Missing or invalid serverURL") + return + } + guard let webView = self.webView else { + call.reject("WebView unavailable for cookie install") + return + } + SessionCookieInstaller.install( + token: token, + server: serverURL, + into: webView + ) { + call.resolve() + } + } + + // MARK: - readSessionCookie + + /// Read the current session token out of WKWebView's cookie jar. + /// JS can't see the cookie any more (it's HttpOnly), so the settings + /// UI uses this to capture the in-flight token when the user enables + /// biometrics mid-session. + /// + /// Expects `{ serverURL: string }` (scheme decides which cookie name + /// to prefer; not used for domain-matching since WKWebView's jar is + /// per-app and may still hold transitional `.vellum.ai`-scoped + /// cookies from earlier app versions). + /// Resolves with `{ token: string | null }`. + @objc public func readSessionCookie(_ call: CAPPluginCall) { + guard let serverURLString = call.getString("serverURL"), + let serverURL = URL(string: serverURLString) else { + call.reject("Missing or invalid serverURL") + return + } + guard let webView = self.webView else { + call.reject("WebView unavailable") + return + } + let preferredName = serverURL.scheme == "https" ? "__Secure-sessionid" : "sessionid" + + DispatchQueue.main.async { + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in + // Prefer the env's canonical name. Only fall back to bare + // `sessionid` if no `__Secure-` cookie exists — upgraded + // users may still carry both in WKWebView's jar from the + // pre-host-only dual-cookie planting. + let match = cookies.first(where: { $0.name == preferredName }) + ?? cookies.first(where: { $0.name == "sessionid" }) + call.resolve(["token": match?.value as Any]) + } + } + } } diff --git a/apps/ios/App/App/SessionCookieInstaller.swift b/apps/ios/App/App/SessionCookieInstaller.swift new file mode 100644 index 00000000000..d8f8e0b9697 --- /dev/null +++ b/apps/ios/App/App/SessionCookieInstaller.swift @@ -0,0 +1,50 @@ +import Foundation +import WebKit + +/// Plants the server session cookie into WKWebView's `WKHTTPCookieStore` +/// after a native auth or biometric-recovery flow returns a session token. +/// +/// Lives on the native side because `document.cookie` in JS (a) cannot set +/// `HttpOnly`, (b) flushes async into WKWebView's store (race against the +/// next navigation), and (c) cannot set a `Max-Age` that the WKWebView +/// will honor across cold launches — the previous JS path produced a +/// session-only cookie that disappeared every time the app was killed. +enum SessionCookieInstaller { + /// Two weeks. Matches the deployed backend's session lifetime. + private static let maxAgeSeconds = 1_209_600 + + static func install( + token: String, + server: URL, + into webView: WKWebView, + completion: @escaping () -> Void + ) { + guard let host = server.host else { + completion() + return + } + + // `__Secure-` is the deployed-env cookie name; bare `sessionid` + // is for the HTTP LAN-IP local dev loop, where the `__Secure-` + // prefix would be rejected by the browser. + let isSecure = server.scheme == "https" + let name = isSecure ? "__Secure-sessionid" : "sessionid" + let secureAttr = isSecure ? "; Secure" : "" + let setCookie = "\(name)=\(token); Domain=\(host); Path=/; Max-Age=\(maxAgeSeconds); HttpOnly; SameSite=Lax\(secureAttr)" + + // `HTTPCookie(properties:)` rejects `HttpOnly` and `SameSite` + // property keys, so parse a real `Set-Cookie` header instead. + guard let cookie = HTTPCookie.cookies( + withResponseHeaderFields: ["Set-Cookie": setCookie], + for: server + ).first else { + completion() + return + } + + DispatchQueue.main.async { + webView.configuration.websiteDataStore.httpCookieStore + .setCookie(cookie, completionHandler: completion) + } + } +} diff --git a/apps/web/src/domains/settings/components/biometric-settings-card.tsx b/apps/web/src/domains/settings/components/biometric-settings-card.tsx index 25d2a186312..c821165eb95 100644 --- a/apps/web/src/domains/settings/components/biometric-settings-card.tsx +++ b/apps/web/src/domains/settings/components/biometric-settings-card.tsx @@ -2,12 +2,13 @@ import { useEffect, useState } from "react"; import { Toggle } from "@vellum/design-library/components/toggle"; import { DetailCard } from "@/components/detail-card"; -import { useIsNativePlatform, getSessionTokenFromCookies } from "@/runtime/native-auth"; +import { deriveAuthBaseURL, useIsNativePlatform } from "@/runtime/native-auth"; import { deleteBiometricToken, getBiometricTypeLabel, isBiometricAvailable, isBiometricEnabled, + readNativeSessionCookie, setBiometricEnabled, storeBiometricToken, } from "@/runtime/native-biometric"; @@ -32,10 +33,15 @@ export function BiometricSettingsCard() { try { const next = !enabled; if (next) { - const token = getSessionTokenFromCookies(); - if (token) { - await storeBiometricToken(token); - } + // The session cookie is HttpOnly, so we read it through the + // native plugin instead of `document.cookie`. Skip flipping the + // preference if we can't capture a token — otherwise the user + // would think biometrics is on, but no token would be in the + // Keychain for recovery later. + const token = await readNativeSessionCookie(deriveAuthBaseURL()); + if (!token) return; + const stored = await storeBiometricToken(token); + if (!stored) return; setBiometricEnabled(true); setEnabled(true); } else { diff --git a/apps/web/src/runtime/native-auth.ts b/apps/web/src/runtime/native-auth.ts index 3ffb1216bdf..ce0a1f454c7 100644 --- a/apps/web/src/runtime/native-auth.ts +++ b/apps/web/src/runtime/native-auth.ts @@ -6,7 +6,6 @@ import { startProviderRedirect, } from "@/domains/account/social-auth"; import { sanitizeReturnTo } from "@/domains/account/return-to"; -import { getSession } from "@/lib/auth/allauth-client"; import { isBiometricEnabled, storeBiometricToken } from "@/runtime/native-biometric"; import { routes } from "@/utils/routes"; @@ -74,10 +73,11 @@ export function useIsNativePlatform(): boolean { /** * Run the native login flow end to end. On success the Django session - * cookie is installed into the WKWebView's cookie jar and the page is - * navigated to `returnTo` (sanitized) or `/assistant`, so `AuthProvider` - * re-fetches `/_allauth/browser/v1/auth/session` and renders the - * authenticated app at the right destination. + * cookie is installed into the WKWebView's cookie jar (by the Swift + * plugin, before the promise resolves) and the page is navigated to + * `returnTo` (sanitized) or `/assistant`, so `AuthProvider` re-fetches + * `/_allauth/browser/v1/auth/session` and renders the authenticated app + * at the right destination. * * Throws on user cancellation (`USER_CANCELLED`) and any other error; the * caller decides whether to surface or swallow. @@ -95,39 +95,6 @@ export async function startNativeLogin(options?: { ...(options?.providerHint ? { providerHint: options.providerHint } : {}), }); - // `document.cookie` can't set HttpOnly, but Django validates the - // session by DB lookup — the HttpOnly flag is client-side only. - // - // We set BOTH `sessionid` (dev) and `__Secure-sessionid` (prod) so - // the same code works across environments without runtime host - // sniffing. Whichever name the server is configured to read, it - // finds. The `__Secure-` prefix has browser-enforced rules: HTTPS - // origin + `Secure` attribute, both of which apply here. - // - // Intentionally NOT using `WKHTTPCookieStore.setCookie()` on the - // Swift side — that was the spike's dead end. The JS-side cookie is - // enough. - installSessionCookies(sessionToken); - - // iOS WKWebView async-flushes `document.cookie` writes to its - // `WKHTTPCookieStore`. Without a synchronization step, the subsequent - // hard navigation can race the flush and the request to `/assistant` - // goes out without the session cookie — Django sees an anonymous user, - // `AuthProvider` redirects back to `/account/login`, and the user is - // dumped at the login screen even though auth itself succeeded. - // - // Probe `/_allauth/browser/v1/auth/session` until the server agrees - // we're authenticated. This both (a) forces WKWebView to flush the - // cookie store so subsequent requests carry the cookie and (b) confirms - // Django actually recognized it before we navigate. - // - // The biometric branch below incidentally awaited enough async work - // to mask the race for biometrics-enabled users, which is why this - // bug only reproduces consistently when biometrics is off. - if (isNativePlatform()) { - await waitForNativeSessionCookie(); - } - // Persist the token in the Keychain for biometric session recovery. // Respects the user's opt-out preference; storeBiometricToken is also // a no-op if biometrics are unavailable on the device. @@ -146,62 +113,6 @@ export async function startNativeLogin(options?: { window.location.href = destination; } -/** - * Block until the just-written session cookie is reachable to Django. - * - * Polls `getSession()` with backoff. Each call is a real same-origin - * fetch with `credentials: "include"`, so iOS WKWebView has to send the - * cookie from its store — if `document.cookie` hasn't flushed yet, the - * server returns anonymous and we retry until it does. - * - * If every attempt fails we still fall through and let the navigation - * proceed; the post-nav `AuthProvider` may succeed once the store - * finally settles, and a stuck loop here would block the user worse - * than a possible re-login. - */ -export async function waitForNativeSessionCookie(): Promise { - const MAX_ATTEMPTS = 6; - for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - try { - const result = await getSession(); - if (result.ok && result.data.user) { - return; - } - } catch { - // Transient network errors fall through to the backoff. - } - await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); - } -} - -/** - * Install Django session cookies for both dev and prod environments. - * Sets both `sessionid` (dev) and `__Secure-sessionid` (prod) so the - * same code works across environments without runtime host sniffing. - */ -export function installSessionCookies(sessionToken: string): void { - const cookieAttrs = "path=/; domain=.vellum.ai; secure; samesite=lax"; - document.cookie = `sessionid=${sessionToken}; ${cookieAttrs}`; - document.cookie = `__Secure-sessionid=${sessionToken}; ${cookieAttrs}`; -} - -/** - * Read the current Django session token from cookies. - * Checks `__Secure-sessionid` (prod) then `sessionid` (dev). - */ -export function getSessionTokenFromCookies(): string | null { - if (typeof document === "undefined") return null; - const cookies = document.cookie.split("; "); - for (const name of ["__Secure-sessionid", "sessionid"]) { - const entry = cookies.find((c) => c.startsWith(`${name}=`)); - if (entry) { - const value = entry.slice(name.length + 1); - if (value) return value; - } - } - return null; -} - /** * Unified auth-flow entry point that transparently chooses between the * native iOS plugin path and the web form-POST path. diff --git a/apps/web/src/runtime/native-biometric.ts b/apps/web/src/runtime/native-biometric.ts index a946a16ef1e..c961e0e6287 100644 --- a/apps/web/src/runtime/native-biometric.ts +++ b/apps/web/src/runtime/native-biometric.ts @@ -31,6 +31,11 @@ interface NativeBiometricPlugin { reason?: string; }): Promise<{ token: string }>; deleteToken(opts: { server: string }): Promise; + installSessionCookie(opts: { + token: string; + serverURL: string; + }): Promise; + readSessionCookie(opts: { serverURL: string }): Promise<{ token: string | null }>; } const NativeBiometric = registerPlugin("NativeBiometric"); @@ -99,6 +104,36 @@ export async function retrieveBiometricToken(): Promise { return pendingRetrieval; } +/** + * Plant a biometric-recovered session token into WKWebView's cookie jar. + * Resolves once the cookie is committed so callers can immediately + * re-probe the session. + */ +export async function installBiometricSessionCookie( + token: string, + serverURL: string, +): Promise { + if (!isNativePlatform()) return; + await NativeBiometric.installSessionCookie({ token, serverURL }); +} + +/** + * Read the current session token from WKWebView's cookie jar. The cookie + * is HttpOnly so `document.cookie` can't see it; the settings UI uses + * this when the user enables biometrics mid-session. + */ +export async function readNativeSessionCookie( + serverURL: string, +): Promise { + if (!isNativePlatform()) return null; + try { + const { token } = await NativeBiometric.readSessionCookie({ serverURL }); + return token ?? null; + } catch { + return null; + } +} + /** * Delete any stored biometric session token. Called on logout to ensure * the next app launch requires a fresh WorkOS login. diff --git a/apps/web/src/stores/auth-store.test.ts b/apps/web/src/stores/auth-store.test.ts index 42c26fd05f7..ca73158f543 100644 --- a/apps/web/src/stores/auth-store.test.ts +++ b/apps/web/src/stores/auth-store.test.ts @@ -18,7 +18,9 @@ const deleteBiometricTokenMock = mock(async () => {}); let mockIsNativePlatform = false; let mockIsBiometricEnabled = false; let mockBiometricToken: string | null = null; -const installSessionCookiesMock = mock((_token: string) => {}); +const installBiometricSessionCookieMock = mock( + async (_token: string, _serverURL: string) => {}, +); const retrieveBiometricTokenMock = mock(async () => mockBiometricToken); mock.module("@/lib/auth/allauth-client", () => ({ @@ -37,12 +39,12 @@ mock.module("@/lib/auth/allauth-client", () => ({ mock.module("@/runtime/native-auth", () => ({ isNativePlatform: () => mockIsNativePlatform, - installSessionCookies: installSessionCookiesMock, - waitForNativeSessionCookie: async () => {}, + deriveAuthBaseURL: () => "https://test.vellum.ai", })); mock.module("@/runtime/native-biometric", () => ({ deleteBiometricToken: deleteBiometricTokenMock, + installBiometricSessionCookie: installBiometricSessionCookieMock, isBiometricEnabled: () => mockIsBiometricEnabled, retrieveBiometricToken: retrieveBiometricTokenMock, })); @@ -93,7 +95,7 @@ beforeEach(() => { clearUserScopedStorageMock.mockClear(); logoutMock.mockClear(); deleteBiometricTokenMock.mockClear(); - installSessionCookiesMock.mockClear(); + installBiometricSessionCookieMock.mockClear(); retrieveBiometricTokenMock.mockClear(); resetAuthStore(); }); @@ -157,8 +159,9 @@ describe("biometric session recovery", () => { await useAuthStore.getState().initSession(); - expect(installSessionCookiesMock).toHaveBeenCalledWith( + expect(installBiometricSessionCookieMock).toHaveBeenCalledWith( "recovered-session-token", + expect.any(String), ); expect(useAuthStore.getState().isLoggedIn).toBe(true); expect(useAuthStore.getState().user?.id).toBe("user-1"); @@ -193,7 +196,10 @@ describe("biometric session recovery", () => { await useAuthStore.getState().initSession(); - expect(installSessionCookiesMock).toHaveBeenCalledWith("expired-token"); + expect(installBiometricSessionCookieMock).toHaveBeenCalledWith( + "expired-token", + expect.any(String), + ); 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 c0ba13435b7..61d2a6deaf5 100644 --- a/apps/web/src/stores/auth-store.ts +++ b/apps/web/src/stores/auth-store.ts @@ -37,8 +37,12 @@ import { syncOnboardingUser, clearOnboardingFlags } from "@/lib/onboarding-clean import { clearOrganization } from "@/stores/organization-store"; import { clearUserScopedStorage } from "@/lib/auth/session-cleanup"; import { useEventBusStore } from "@/stores/event-bus-store"; -import { isNativePlatform, installSessionCookies, waitForNativeSessionCookie } from "@/runtime/native-auth"; -import { isBiometricEnabled, retrieveBiometricToken } from "@/runtime/native-biometric"; +import { deriveAuthBaseURL, isNativePlatform } from "@/runtime/native-auth"; +import { + installBiometricSessionCookie, + isBiometricEnabled, + retrieveBiometricToken, +} from "@/runtime/native-biometric"; export interface AuthUser { id: string | null; @@ -168,14 +172,13 @@ const useAuthStoreBase = create()((set) => ({ 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. + // Biometric recovery: only reached when the persistent session + // cookie has expired or been cleared (e.g. user wiped Safari data). if (isNativePlatform() && isBiometricEnabled()) { try { const token = await retrieveBiometricToken(); if (token) { - installSessionCookies(token); - await waitForNativeSessionCookie(); + await installBiometricSessionCookie(token, deriveAuthBaseURL()); const retryResult = await getSession(); if (retryResult.ok && retryResult.data.user) { const user = toAuthUser(retryResult.data.user);