UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

229 lines (228 loc) 8.9 kB
import { parseSetCookieHeader, splitSetCookieHeader } from "../../cookies/cookie-utils.mjs"; import { generateRandomString } from "../../crypto/random.mjs"; import { expireCookie } from "../../cookies/index.mjs"; import { getAwaitableValue } from "../../context/helpers.mjs"; import { setOAuthState } from "../../api/state/oauth.mjs"; import { INTERNAL_STATE_KEYS, generateGenericState } from "../../state.mjs"; import { HIDE_METADATA } from "../../utils/hide-metadata.mjs"; import { PACKAGE_VERSION } from "../../version.mjs"; import { OAUTH_POPUP_DATA_ELEMENT_ID, OAUTH_POPUP_MESSAGE_TYPE, POPUP_MARKER_COOKIE } from "./constants.mjs"; import { OAUTH_POPUP_ERROR_CODES } from "./error-codes.mjs"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import { safeJSONParse } from "@better-auth/core/utils/json"; import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api"; import * as z from "zod"; //#region src/plugins/oauth-popup/index.ts let warnedMissingBearer = false; /** * Escapes `<\/script>` and JS line separators for embedding in a script element. */ function inlineJSON(value) { return JSON.stringify(value).replace(/[<\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`); } /** * The completion-page script. */ const OAUTH_POPUP_COMPLETE_SCRIPT = `(function () { var el = document.getElementById(${JSON.stringify(OAUTH_POPUP_DATA_ELEMENT_ID)}); if (!el) return; var payload; try { payload = JSON.parse(el.textContent || ""); } catch (e) { return; } var target = window.opener || window.parent; if (target && target !== window) { try { target.postMessage( { type: payload.type, nonce: payload.nonce, token: payload.token, redirectTo: payload.redirectTo, error: payload.error, }, payload.targetOrigin, ); } catch (e) {} } window.close(); })(); `; /** * sha256 of `OAUTH_POPUP_COMPLETE_SCRIPT`, pinned in the completion CSP. */ const OAUTH_POPUP_SCRIPT_CSP_HASH = "sha256-tIo2K8VBC9SnhvdZ+9GsGkQoZm+jm/JcxL+d+i8b8KQ="; /** * Renders the page that posts the outcome (token or error) to the opener. The * caller must pass a trusted `popupOrigin` — validated at `/oauth-popup/start` * and preserved in the signed marker cookie the callback reads. */ function renderCompletion(c, popupOrigin, message) { if (message.token && !warnedMissingBearer && !c.context.hasPlugin("bearer")) { warnedMissingBearer = true; c.context.logger.warn("OAuth popup hands the session token back via postMessage, but the `bearer` plugin is not registered, so an embedded (cross-site iframe) app cannot authenticate with it. Add bearer() to your auth `plugins`."); } const html = `<!doctype html> <html> <head><meta charset="utf-8"><title>Completing sign-in</title></head> <body> <script type="application/json" id="${OAUTH_POPUP_DATA_ELEMENT_ID}">${inlineJSON({ type: OAUTH_POPUP_MESSAGE_TYPE, targetOrigin: popupOrigin, ...message })}<\/script> <script>${OAUTH_POPUP_COMPLETE_SCRIPT}<\/script> </body> </html>`; return new Response(html, { status: 200, headers: { "content-type": "text/html; charset=utf-8", "content-security-policy": `default-src 'none'; script-src '${OAUTH_POPUP_SCRIPT_CSP_HASH}'; base-uri 'none'`, "cache-control": "no-store", pragma: "no-cache" } }); } /** * Starts the OAuth flow for a popup. The popup navigates here (top-level, so it * is first-party to the auth origin even when the app is on another origin), * the server sets the state + opener-marker cookies in the right partition, then * redirects to the provider. The callback then renders the completion page. */ const oauthPopupStart = createAuthEndpoint("/oauth-popup/start", { method: "GET", query: z.object({ provider: z.string(), popupOrigin: z.string(), popupNonce: z.string().optional(), callbackURL: z.string().optional(), errorCallbackURL: z.string().optional(), newUserCallbackURL: z.string().optional(), scopes: z.string().optional(), requestSignUp: z.string().optional(), additionalData: z.string().optional() }), metadata: HIDE_METADATA }, async (c) => { const { popupOrigin } = c.query; if (!c.context.isTrustedOrigin(popupOrigin, { allowRelativePaths: false })) { c.context.logger.error(`OAuth popup origin is not a trusted origin. Add ${popupOrigin} to trustedOrigins.`); throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.INVALID_ORIGIN); } const fail = (code, description) => renderCompletion(c, popupOrigin, { nonce: c.query.popupNonce ?? "", error: { code, description } }); const validateRedirect = (url, code) => { if (!url || c.context.isTrustedOrigin(url, { allowRelativePaths: true })) return; c.context.logger.error(`Invalid redirect URL: ${url}`); return fail(code, `Untrusted URL: ${url}`); }; const invalidRedirect = validateRedirect(c.query.callbackURL, "invalid_callback_url") ?? validateRedirect(c.query.errorCallbackURL, "invalid_error_callback_url") ?? validateRedirect(c.query.newUserCallbackURL, "invalid_new_user_callback_url"); if (invalidRedirect) return invalidRedirect; const provider = await getAwaitableValue(c.context.socialProviders, { value: c.query.provider }); if (!provider) return fail("provider_not_found", `Unknown provider: ${c.query.provider}`); const callbackURL = c.query.callbackURL || c.context.baseURL; let url; try { const codeVerifier = generateRandomString(128); const parsedAdditionalData = c.query.additionalData ? safeJSONParse(c.query.additionalData) ?? {} : {}; const stateData = { ...Object.fromEntries(Object.entries(parsedAdditionalData).filter(([key]) => !INTERNAL_STATE_KEYS.has(key))), callbackURL, codeVerifier, errorURL: c.query.errorCallbackURL, newUserURL: c.query.newUserCallbackURL, requestSignUp: c.query.requestSignUp === "true" ? true : void 0, expiresAt: Date.now() + 600 * 1e3 }; await setOAuthState(stateData); const { state } = await generateGenericState(c, stateData); const marker = c.context.createAuthCookie(POPUP_MARKER_COOKIE, { maxAge: 600 }); await c.setSignedCookie(marker.name, JSON.stringify({ popupOrigin, popupNonce: c.query.popupNonce ?? "" }), c.context.secret, marker.attributes); url = await provider.createAuthorizationURL({ state, codeVerifier, redirectURI: `${c.context.baseURL}/callback/${provider.id}`, scopes: c.query.scopes ? c.query.scopes.split(",") : void 0 }); } catch (error) { c.context.logger.error("OAuth popup failed to start", error); return fail("popup_sign_in_failed", "Failed to start the OAuth flow."); } throw c.redirect(url.toString()); }); /** * Server plugin for popup-based OAuth. `signIn.popup` navigates the popup to * `/oauth-popup/start`; on the OAuth callback this plugin swaps the redirect for * a page that posts the session token (or error) back to the opener. Pair with * the `bearer` plugin and `oauthPopupClient`. */ const oauthPopup = () => { return { id: "oauth-popup", version: PACKAGE_VERSION, $ERROR_CODES: OAUTH_POPUP_ERROR_CODES, endpoints: { oauthPopupStart }, hooks: { after: [{ matcher(context) { return !!(context.path?.startsWith("/callback/") || context.path?.startsWith("/oauth2/callback/")); }, handler: createAuthMiddleware(async (c) => { const redirectTo = c.context.responseHeaders?.get("location"); if (!redirectTo) return; const cookie = c.context.createAuthCookie(POPUP_MARKER_COOKIE); const marker = await c.getSignedCookie(cookie.name, c.context.secret); if (!marker) return; expireCookie(c, cookie); let popupOrigin; let popupNonce; try { const parsed = JSON.parse(marker); popupOrigin = parsed.popupOrigin; popupNonce = parsed.popupNonce ?? ""; } catch { return; } const setCookies = c.context.responseHeaders?.getSetCookie?.() ?? splitSetCookieHeader(c.context.responseHeaders?.get("set-cookie") ?? ""); let token; for (const raw of setCookies) { const value = parseSetCookieHeader(raw).get(c.context.authCookies.sessionToken.name)?.value; if (value !== void 0) { token = value; break; } } let response; if (token) response = renderCompletion(c, popupOrigin, { nonce: popupNonce, token, redirectTo }); else { const error = new URL(redirectTo, c.context.baseURL).searchParams.get("error"); if (!error) return; response = renderCompletion(c, popupOrigin, { nonce: popupNonce, error: { code: error, description: new URL(redirectTo, c.context.baseURL).searchParams.get("error_description") ?? void 0 } }); } c.context.returned = response; }) }] } }; }; //#endregion export { OAUTH_POPUP_COMPLETE_SCRIPT, OAUTH_POPUP_DATA_ELEMENT_ID, OAUTH_POPUP_ERROR_CODES, OAUTH_POPUP_MESSAGE_TYPE, OAUTH_POPUP_SCRIPT_CSP_HASH, POPUP_MARKER_COOKIE, oauthPopup };