// WebAuthn passkey client (auth-only — no PRF / key-wrapping). Shared across // Entropy Softworks Expo apps. Drives the OS authenticator (Face ID / Touch ID // / device passkey) and talks to the server's /auth/passkey/* ceremony // endpoints. // // Native (Expo dev build / store build) uses `react-native-passkey`, which // consumes/produces the WebAuthn JSON shapes directly (base64url strings). Web // uses the DOM `navigator.credentials` API, which needs ArrayBuffers, so the // base64url fields are rewritten in and serialized back out. // // NOTE: passkeys require a native build (not Expo Go) and an associated-domain // / asset-link binding the app to the server's WEBAUTHN_RP_ID. `react-native- // passkey` is a lazy import so apps/platforms that never call these functions // don't need it linked. import { Platform } from "react-native"; import { getBaseUrl, getSessionToken, storeSessionToken } from "./api-config"; import type { AuthResponse } from "./auth.service"; // ── base64url ⇄ ArrayBuffer (web path only) ──────────────────────────────── function b64urlToBuffer(value: string): ArrayBuffer { const pad = "=".repeat((4 - (value.length % 4)) % 4); const base64 = (value + pad).replace(/-/g, "+").replace(/_/g, "/"); const bin = atob(base64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) { bytes[i] = bin.charCodeAt(i); } return bytes.buffer; } function bufferToB64url(buf: ArrayBuffer): string { const bytes = new Uint8Array(buf); let bin = ""; for (const b of bytes) { bin += String.fromCharCode(b); } return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } // ── transport ─────────────────────────────────────────────────────────────── async function post(path: string, body: unknown): Promise { const headers: Record = { "Content-Type": "application/json" }; const token = await getSessionToken(); if (token) { headers.Authorization = `Bearer ${token}`; } return fetch(`${getBaseUrl()}${path}`, { method: "POST", headers, credentials: "include", body: JSON.stringify(body), }); } async function postJson(path: string, body: unknown): Promise { const res = await post(path, body); if (!res.ok) { let message = `request failed (${res.status})`; try { const data = await res.json(); message = data?.error?.message ?? message; } catch { // non-JSON error body; keep the status message } throw new Error(message); } return (await res.json()) as T; } interface BeginResponse { ceremonyId: string; publickeyOptions: Record; } // react-native-passkey is a native module; import lazily so the web bundle and // type-checking don't hard-depend on it being linked. async function nativePasskey() { const mod = await import("react-native-passkey"); return mod.Passkey; } /** Whether this platform can do WebAuthn at all. */ export async function isPasskeySupported(): Promise { if (Platform.OS === "web") { return ( typeof globalThis !== "undefined" && // eslint-disable-next-line @typescript-eslint/no-explicit-any !!(globalThis as any).PublicKeyCredential ); } try { const Passkey = await nativePasskey(); return Boolean((Passkey as { isSupported?: () => boolean }).isSupported?.()); } catch { return false; } } /** The inner `publicKey` object webauthn-rs wraps its options in. */ function inner(options: Record): Record { const pk = options.publicKey; return (pk && typeof pk === "object" ? pk : options) as Record; } async function createCredential(options: Record): Promise { if (Platform.OS === "web") { const pk = { ...inner(options) }; pk.challenge = b64urlToBuffer(pk.challenge as string); const user = pk.user as Record | undefined; if (user && typeof user.id === "string") { pk.user = { ...user, id: b64urlToBuffer(user.id) }; } if (Array.isArray(pk.excludeCredentials)) { pk.excludeCredentials = (pk.excludeCredentials as Record[]).map((c) => ({ ...c, id: typeof c.id === "string" ? b64urlToBuffer(c.id) : c.id, })); } // eslint-disable-next-line @typescript-eslint/no-explicit-any const cred = (await navigator.credentials.create({ publicKey: pk as any })) as any; return serializeWebCredential(cred); } const Passkey = await nativePasskey(); // eslint-disable-next-line @typescript-eslint/no-explicit-any return (Passkey as any).create(inner(options)); } async function getCredential(options: Record): Promise { if (Platform.OS === "web") { const pk = { ...inner(options) }; pk.challenge = b64urlToBuffer(pk.challenge as string); if (Array.isArray(pk.allowCredentials)) { pk.allowCredentials = (pk.allowCredentials as Record[]).map((c) => ({ ...c, id: typeof c.id === "string" ? b64urlToBuffer(c.id) : c.id, })); } // eslint-disable-next-line @typescript-eslint/no-explicit-any const cred = (await navigator.credentials.get({ publicKey: pk as any })) as any; return serializeWebCredential(cred); } const Passkey = await nativePasskey(); // eslint-disable-next-line @typescript-eslint/no-explicit-any return (Passkey as any).get(inner(options)); } // Convert a DOM PublicKeyCredential into the base64url JSON the server expects. // eslint-disable-next-line @typescript-eslint/no-explicit-any function serializeWebCredential(cred: any): unknown { const r = cred.response; const out: Record = { id: cred.id, rawId: bufferToB64url(cred.rawId), type: cred.type, response: {} as Record, }; const resp = out.response as Record; if (r.attestationObject) { resp.attestationObject = bufferToB64url(r.attestationObject); } if (r.clientDataJSON) { resp.clientDataJSON = bufferToB64url(r.clientDataJSON); } if (r.authenticatorData) { resp.authenticatorData = bufferToB64url(r.authenticatorData); } if (r.signature) { resp.signature = bufferToB64url(r.signature); } if (r.userHandle) { resp.userHandle = bufferToB64url(r.userHandle); } return out; } function clientExtensionResults(cred: unknown): Record | undefined { // eslint-disable-next-line @typescript-eslint/no-explicit-any const c = cred as any; if (typeof c?.getClientExtensionResults === "function") { return c.getClientExtensionResults(); } return c?.clientExtensionResults; } /** Register a new passkey for the signed-in user. Triggers the OS biometric. */ export async function registerPasskey(label: string): Promise<{ label: string }> { const begin = await postJson("/auth/passkey/register/begin", { label }); const credential = await createCredential(begin.publickeyOptions); return postJson<{ label: string }>("/auth/passkey/register/finish", { ceremonyId: begin.ceremonyId, label, credential, clientExtensionResults: clientExtensionResults(credential), }); } /** Sign in with a passkey. Returns the authenticated session. */ export async function authenticateWithPasskey(email?: string): Promise { const begin = await postJson("/auth/passkey/authenticate/begin", { email }); const credential = await getCredential(begin.publickeyOptions); const auth = await postJson("/auth/passkey/authenticate/finish", { ceremonyId: begin.ceremonyId, credential, clientExtensionResults: clientExtensionResults(credential), }); if (auth.session_token) { await storeSessionToken(auth.session_token); } return auth; }