UNPKG

@better-auth/expo

Version:

Better Auth integration for Expo and React Native applications.

363 lines (359 loc) 12.4 kB
import { safeJSONParse } from "@better-auth/core/utils"; import { SECURE_COOKIE_PREFIX, stripSecureCookiePrefix } from "better-auth/cookies"; import Constants from "expo-constants"; import * as Linking from "expo-linking"; import { AppState, Platform } from "react-native"; import { kFocusManager, kOnlineManager } from "better-auth/client"; //#region src/focus-manager.ts var ExpoFocusManager = class { listeners = /* @__PURE__ */ new Set(); subscription; subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } setFocused(focused) { this.listeners.forEach((listener) => listener(focused)); } setup() { this.subscription = AppState.addEventListener("change", (state) => { this.setFocused(state === "active"); }); return () => { this.subscription?.remove(); }; } }; function setupExpoFocusManager() { if (!globalThis[kFocusManager]) globalThis[kFocusManager] = new ExpoFocusManager(); return globalThis[kFocusManager]; } //#endregion //#region src/online-manager.ts var ExpoOnlineManager = class { listeners = /* @__PURE__ */ new Set(); isOnline = true; unsubscribe; subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } setOnline(online) { this.isOnline = online; this.listeners.forEach((listener) => listener(online)); } setup() { import("expo-network").then(({ addNetworkStateListener }) => { const subscription = addNetworkStateListener((state) => { this.setOnline(!!state.isInternetReachable); }); this.unsubscribe = () => subscription.remove(); }).catch(() => { this.setOnline(true); }); return () => { this.unsubscribe?.(); }; } }; function setupExpoOnlineManager() { if (!globalThis[kOnlineManager]) globalThis[kOnlineManager] = new ExpoOnlineManager(); return globalThis[kOnlineManager]; } //#endregion //#region src/client.ts if (Platform.OS !== "web") { setupExpoFocusManager(); setupExpoOnlineManager(); } function parseSetCookieHeader(header) { const cookieMap = /* @__PURE__ */ new Map(); splitSetCookieHeader(header).forEach((cookie) => { const [nameValue, ...attributes] = cookie.split(";").map((p) => p.trim()); const [name, ...valueParts] = (nameValue || "").split("="); const value = valueParts.join("="); if (!name || value === void 0) return; const attrObj = { value }; attributes.forEach((attr) => { const [attrName, ...attrValueParts] = attr.split("="); if (!attrName?.trim()) return; const attrValue = attrValueParts.join("="); const normalizedAttrName = attrName.trim().toLowerCase(); attrObj[normalizedAttrName] = attrValue; }); cookieMap.set(name, attrObj); }); return cookieMap; } function splitSetCookieHeader(setCookie) { const parts = []; let buffer = ""; let i = 0; while (i < setCookie.length) { const char = setCookie[i]; if (char === ",") { const recent = buffer.toLowerCase(); const hasExpires = recent.includes("expires="); const hasGmt = /gmt/i.test(recent); if (hasExpires && !hasGmt) { buffer += char; i += 1; continue; } if (buffer.trim().length > 0) { parts.push(buffer.trim()); buffer = ""; } i += 1; if (setCookie[i] === " ") i += 1; continue; } buffer += char; i += 1; } if (buffer.trim().length > 0) parts.push(buffer.trim()); return parts; } function getSetCookie(header, prevCookie) { const parsed = parseSetCookieHeader(header); let toSetCookie = {}; parsed.forEach((cookie, key) => { const expiresAt = cookie["expires"]; const maxAge = cookie["max-age"]; const expires = maxAge ? new Date(Date.now() + Number(maxAge) * 1e3) : expiresAt ? new Date(String(expiresAt)) : null; toSetCookie[key] = { value: cookie["value"], expires: expires ? expires.toISOString() : null }; }); if (prevCookie) try { toSetCookie = { ...JSON.parse(prevCookie), ...toSetCookie }; } catch {} return JSON.stringify(toSetCookie); } function getCookie(cookie) { let parsed = {}; try { parsed = JSON.parse(cookie); } catch {} return Object.entries(parsed).reduce((acc, [key, value]) => { if (value.expires && new Date(value.expires) < /* @__PURE__ */ new Date()) return acc; return `${acc}; ${key}=${value.value}`; }, ""); } function getOAuthStateValue(cookieJson, cookiePrefix) { if (!cookieJson) return null; const parsed = safeJSONParse(cookieJson); if (!parsed) return null; const prefixes = Array.isArray(cookiePrefix) ? cookiePrefix : [cookiePrefix]; for (const prefix of prefixes) { const candidates = [`${SECURE_COOKIE_PREFIX}${prefix}.oauth_state`, `${prefix}.oauth_state`]; for (const name of candidates) { const value = parsed?.[name]?.value; if (value) return value; } } return null; } function getOrigin(scheme) { return Linking.createURL("", { scheme }); } /** * Compare if session cookies have actually changed by comparing their values. * Ignores expiry timestamps that naturally change on each request. * * @param prevCookie - Previous cookie JSON string * @param newCookie - New cookie JSON string * @returns true if session cookies have changed, false otherwise */ function hasSessionCookieChanged(prevCookie, newCookie) { if (!prevCookie) return true; try { const prev = JSON.parse(prevCookie); const next = JSON.parse(newCookie); const sessionKeys = /* @__PURE__ */ new Set(); Object.keys(prev).forEach((key) => { if (key.includes("session_token") || key.includes("session_data")) sessionKeys.add(key); }); Object.keys(next).forEach((key) => { if (key.includes("session_token") || key.includes("session_data")) sessionKeys.add(key); }); for (const key of sessionKeys) if (prev[key]?.value !== next[key]?.value) return true; return false; } catch { return true; } } /** * Check if the Set-Cookie header contains better-auth cookies. * This prevents infinite refetching when non-better-auth cookies (like third-party cookies) change. * * Supports multiple cookie naming patterns: * - Default: "better-auth.session_token", "better-auth-passkey", "__Secure-better-auth.session_token" * - Custom prefix: "myapp.session_token", "myapp-passkey", "__Secure-myapp.session_token" * - Custom full names: "my_custom_session_token", "custom_session_data" * - No prefix (cookiePrefix=""): matches any cookie with known suffixes * - Multiple prefixes: ["better-auth", "my-app"] matches cookies starting with any of the prefixes * * @param setCookieHeader - The Set-Cookie header value * @param cookiePrefix - The cookie prefix(es) to check for. Can be a string, array of strings, or empty string. * @returns true if the header contains better-auth cookies, false otherwise */ function hasBetterAuthCookies(setCookieHeader, cookiePrefix) { const cookies = parseSetCookieHeader(setCookieHeader); const cookieSuffixes = ["session_token", "session_data"]; const prefixes = Array.isArray(cookiePrefix) ? cookiePrefix : [cookiePrefix]; for (const name of cookies.keys()) { const nameWithoutSecure = stripSecureCookiePrefix(name); for (const prefix of prefixes) if (prefix) { if (nameWithoutSecure.startsWith(prefix)) return true; } else for (const suffix of cookieSuffixes) if (nameWithoutSecure.endsWith(suffix)) return true; } return false; } /** * Expo secure store does not support colons in the keys. * This function replaces colons with underscores. * * @see https://github.com/better-auth/better-auth/issues/5426 * * @param name cookie name to be saved in the storage * @returns normalized cookie name */ function normalizeCookieName(name) { return name.replace(/:/g, "_"); } function storageAdapter(storage) { return { getItem: (name) => { return storage.getItem(normalizeCookieName(name)); }, setItem: (name, value) => { return storage.setItem(normalizeCookieName(name), value); } }; } const expoClient = (opts) => { let store = null; const storagePrefix = opts?.storagePrefix || "better-auth"; const cookieName = `${storagePrefix}_cookie`; const localCacheName = `${storagePrefix}_session_data`; const storage = storageAdapter(opts?.storage); const isWeb = Platform.OS === "web"; const cookiePrefix = opts?.cookiePrefix || "better-auth"; const rawScheme = opts?.scheme || Constants.expoConfig?.scheme || Constants.platform?.scheme; const scheme = Array.isArray(rawScheme) ? rawScheme[0] : rawScheme; if (!scheme && !isWeb) throw new Error("Scheme not found in app.json. Please provide a scheme in the options."); return { id: "expo", getActions(_, $store) { store = $store; return { getCookie: () => { return getCookie(storage.getItem(cookieName) || "{}"); } }; }, fetchPlugins: [{ id: "expo", name: "Expo", hooks: { async onSuccess(context) { if (isWeb) return; const setCookie = context.response.headers.get("set-cookie"); if (setCookie) { if (hasBetterAuthCookies(setCookie, cookiePrefix)) { const prevCookie = await storage.getItem(cookieName); const toSetCookie = getSetCookie(setCookie || "", prevCookie ?? void 0); if (hasSessionCookieChanged(prevCookie, toSetCookie)) { storage.setItem(cookieName, toSetCookie); store?.notify("$sessionSignal"); } else await storage.setItem(cookieName, toSetCookie); } } if (context.request.url.toString().includes("/get-session") && !opts?.disableCache) { const data = context.data; storage.setItem(localCacheName, JSON.stringify(data)); } if (context.data?.redirect && (context.request.url.toString().includes("/sign-in") || context.request.url.toString().includes("/link-social")) && !context.request?.body.includes("idToken")) { const to = JSON.parse(context.request.body)?.callbackURL; const signInURL = context.data?.url; let Browser = void 0; try { Browser = await import("expo-web-browser"); } catch (error) { throw new Error("\"expo-web-browser\" is not installed as a dependency!", { cause: error }); } if (Platform.OS === "android") try { Browser.dismissAuthSession(); } catch {} const oauthStateValue = getOAuthStateValue(await storage.getItem(cookieName), cookiePrefix); const params = new URLSearchParams({ authorizationURL: signInURL }); if (oauthStateValue) params.append("oauthState", oauthStateValue); const proxyURL = `${context.request.baseURL}/expo-authorization-proxy?${params.toString()}`; const result = await Browser.openAuthSessionAsync(proxyURL, to, opts?.webBrowserOptions); if (result.type !== "success") return; const url = new URL(result.url); const cookie = String(url.searchParams.get("cookie")); if (!cookie) return; const toSetCookie = getSetCookie(cookie, await storage.getItem(cookieName) ?? void 0); storage.setItem(cookieName, toSetCookie); store?.notify("$sessionSignal"); } } }, async init(url, options) { if (isWeb) return { url, options }; options = options || {}; const cookie = getCookie(storage.getItem(cookieName) || "{}"); options.credentials = "omit"; options.headers = { ...options.headers, cookie, "expo-origin": getOrigin(scheme), "x-skip-oauth-proxy": "true" }; if (options.body?.callbackURL) { if (options.body.callbackURL.startsWith("/")) { const url$1 = Linking.createURL(options.body.callbackURL, { scheme }); options.body.callbackURL = url$1; } } if (options.body?.newUserCallbackURL) { if (options.body.newUserCallbackURL.startsWith("/")) { const url$1 = Linking.createURL(options.body.newUserCallbackURL, { scheme }); options.body.newUserCallbackURL = url$1; } } if (options.body?.errorCallbackURL) { if (options.body.errorCallbackURL.startsWith("/")) { const url$1 = Linking.createURL(options.body.errorCallbackURL, { scheme }); options.body.errorCallbackURL = url$1; } } if (url.includes("/sign-out")) { await storage.setItem(cookieName, "{}"); store?.atoms.session?.set({ ...store.atoms.session.get(), data: null, error: null, isPending: false }); storage.setItem(localCacheName, "{}"); } return { url, options }; } }] }; }; //#endregion export { expoClient, getCookie, getSetCookie, hasBetterAuthCookies, normalizeCookieName, parseSetCookieHeader, setupExpoFocusManager, setupExpoOnlineManager, storageAdapter };