UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

206 lines (205 loc) • 7.42 kB
import { getBaseURL } from "../../utils/url.mjs"; import { PACKAGE_VERSION } from "../../version.mjs"; import { POPUP_TOKEN_STORAGE_KEY } from "./constants.mjs"; import { OAUTH_POPUP_ERROR_CODES } from "./error-codes.mjs"; //#region src/plugins/oauth-popup/client.ts const POPUP_NAME = "better-auth-oauth"; const POPUP_WIDTH = 500; const POPUP_HEIGHT = 600; const CLOSED_POLL_MS = 500; const DEFAULT_TIMEOUT_MS = 300 * 1e3; /** True when embedded cross-origin, where the cookie may be partitioned. */ function isEmbedded() { if (typeof window === "undefined" || window.self === window.top) return false; try { window.top?.location.href; return false; } catch { return true; } } /** Reads the stored popup token (browser-only; null otherwise). */ function getStoredPopupToken() { if (typeof window === "undefined") return null; try { return window.localStorage.getItem(POPUP_TOKEN_STORAGE_KEY); } catch { return null; } } function storePopupToken(token) { try { window.localStorage?.setItem(POPUP_TOKEN_STORAGE_KEY, token); } catch {} } function clearPopupToken() { try { window.localStorage?.removeItem(POPUP_TOKEN_STORAGE_KEY); } catch {} } /** * Attaches the popup token as a bearer header when embedded (where the cookie is * partitioned), and clears it once the session ends so it can't be reused. */ const popupBearerFetchPlugin = { id: "better-auth-popup-bearer", name: "Popup Bearer", hooks: { onRequest(context) { if (!isEmbedded()) return context; const token = getStoredPopupToken(); if (!token) return context; const headers = new Headers(context.headers); if (!headers.has("authorization")) headers.set("authorization", `Bearer ${token}`); return { ...context, headers }; }, onSuccess(context) { const path = new URL(context.request.url).pathname; if (path.endsWith("/sign-out") || path.endsWith("/revoke-session") || path.endsWith("/revoke-sessions") || path.endsWith("/revoke-other-sessions") || path.endsWith("/delete-user")) clearPopupToken(); } } }; let activePopup = null; function popupError(code, status) { return { data: null, error: { code, message: String(OAUTH_POPUP_ERROR_CODES[code]), ...status ? { status } : {} } }; } function centeredFeatures() { const left = window.screenX + (window.outerWidth - POPUP_WIDTH) / 2; const top = window.screenY + (window.outerHeight - POPUP_HEIGHT) / 2; return `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},menubar=no,toolbar=no`; } function randomNonce() { const bytes = /* @__PURE__ */ new Uint8Array(16); crypto.getRandomValues(bytes); return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } /** * Resolves with the token (or relayed error) once the completion page posts * back, gating on origin, type, and nonce. */ function waitForPopupResult(popup, expectedOrigin, nonce, timeoutMs) { return new Promise((resolve) => { let settled = false; const settle = (outcome) => { if (settled) return; settled = true; window.removeEventListener("message", onMessage); clearInterval(closedPoll); clearTimeout(timeout); try { if (!popup.closed) popup.close(); } catch {} resolve(outcome); }; const onMessage = (event) => { if (event.origin !== expectedOrigin) return; const data = event.data; if (data?.type !== "better-auth:oauth-popup") return; if (data.nonce !== nonce) return; if (data.error) { settle({ status: "error", error: data.error }); return; } if (typeof data.token !== "string" || !data.token) return; settle({ status: "success", token: data.token }); }; const closedPoll = setInterval(() => { if (popup.closed) settle({ status: "cancelled" }); }, CLOSED_POLL_MS); const timeout = setTimeout(() => settle({ status: "timeout" }), timeoutMs); window.addEventListener("message", onMessage); }); } function resolveAuthURL(options) { const configured = getBaseURL(options?.baseURL, options?.basePath) ?? options?.basePath ?? "/api/auth"; return new URL(configured, window.location.origin); } /** * Builds `signIn.popup`. Runs the sign-in in the popup's own first-party * context (so the OAuth state cookie lands there), waits for the completion * page to post the session token back, stores it for the bearer fetch plugin, * and refreshes the reactive session. */ function createSignInPopup({ $fetch, options, notifySessionSignal }) { return async function signInPopup(opts) { if (typeof window === "undefined") return popupError("POPUP_SIGN_IN_FAILED"); const { provider, providerId, additionalData, windowFeatures, timeoutMs = DEFAULT_TIMEOUT_MS, callbackURL, errorCallbackURL, newUserCallbackURL, scopes, requestSignUp } = opts; if (!provider && !providerId) return popupError("POPUP_SIGN_IN_FAILED"); if (activePopup && !activePopup.closed) { activePopup.focus(); return popupError("POPUP_SIGN_IN_FAILED"); } const nonce = randomNonce(); const authUrl = resolveAuthURL(options); const authOrigin = authUrl.origin; const startUrl = new URL(`${authUrl.href.replace(/\/$/, "")}/oauth-popup/start`); startUrl.searchParams.set("provider", provider ?? providerId); startUrl.searchParams.set("popupOrigin", window.location.origin); startUrl.searchParams.set("popupNonce", nonce); if (callbackURL) startUrl.searchParams.set("callbackURL", callbackURL); if (errorCallbackURL) startUrl.searchParams.set("errorCallbackURL", errorCallbackURL); if (newUserCallbackURL) startUrl.searchParams.set("newUserCallbackURL", newUserCallbackURL); if (scopes?.length) startUrl.searchParams.set("scopes", scopes.join(",")); if (requestSignUp) startUrl.searchParams.set("requestSignUp", "true"); if (additionalData) startUrl.searchParams.set("additionalData", JSON.stringify(additionalData)); const popup = window.open(startUrl.toString(), POPUP_NAME, windowFeatures ?? centeredFeatures()); if (!popup) return popupError("POPUP_BLOCKED"); activePopup = popup; const outcome = await waitForPopupResult(popup, authOrigin, nonce, timeoutMs); activePopup = null; if (outcome.status === "timeout") return popupError("POPUP_TIMEOUT"); if (outcome.status === "cancelled") return popupError("POPUP_CLOSED"); if (outcome.status === "error") return { data: null, error: { code: outcome.error.code, message: outcome.error.description || outcome.error.code } }; if (isEmbedded()) storePopupToken(outcome.token); else clearPopupToken(); const session = await $fetch("/get-session"); if (session.error || !session.data) return popupError("POPUP_SIGN_IN_FAILED", session.error?.status); notifySessionSignal(); return { data: { success: true }, error: null }; }; } /** * Client plugin for popup OAuth sign-in. Adds `authClient.signIn.popup`. Pair * with the server `oauthPopup` and `bearer` plugins. */ const oauthPopupClient = () => { return { id: "oauth-popup", version: PACKAGE_VERSION, $InferServerPlugin: {}, $ERROR_CODES: OAUTH_POPUP_ERROR_CODES, fetchPlugins: [popupBearerFetchPlugin], getActions: ($fetch, $store, options) => ({ signIn: { popup: createSignInPopup({ $fetch, options, notifySessionSignal: () => $store.notify("$sessionSignal") }) } }) }; }; //#endregion export { OAUTH_POPUP_ERROR_CODES, POPUP_TOKEN_STORAGE_KEY, createSignInPopup, getStoredPopupToken, oauthPopupClient, popupBearerFetchPlugin };