@better-auth/expo
Version:
Better Auth integration for Expo and React Native applications.
363 lines (359 loc) • 12.4 kB
JavaScript
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 };