Skip to content
Closed
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
15 changes: 14 additions & 1 deletion apps/ios/App/App/NativeAuthPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
71 changes: 71 additions & 0 deletions apps/ios/App/App/NativeBiometricPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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])
Comment thread
clopen-set marked this conversation as resolved.
}
}
}
}
50 changes: 50 additions & 0 deletions apps/ios/App/App/SessionCookieInstaller.swift
Original file line number Diff line number Diff line change
@@ -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)"
Comment thread
clopen-set marked this conversation as resolved.

// `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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down
99 changes: 5 additions & 94 deletions apps/web/src/runtime/native-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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<void> {
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.
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/runtime/native-biometric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ interface NativeBiometricPlugin {
reason?: string;
}): Promise<{ token: string }>;
deleteToken(opts: { server: string }): Promise<void>;
installSessionCookie(opts: {
token: string;
serverURL: string;
}): Promise<void>;
readSessionCookie(opts: { serverURL: string }): Promise<{ token: string | null }>;
}

const NativeBiometric = registerPlugin<NativeBiometricPlugin>("NativeBiometric");
Expand Down Expand Up @@ -99,6 +104,36 @@ export async function retrieveBiometricToken(): Promise<string | null> {
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<void> {
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<string | null> {
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.
Expand Down
Loading