From 235b09484b19cdc19125fd3fe8b67fefd4935f37 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Tue, 11 Nov 2025 08:11:32 +0900 Subject: [PATCH] fix(oauth-proxy): handle cross-origin flows (#5729) Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/plugins/oauth-proxy/index.ts | 216 ++++++++++++------ 1 file changed, 143 insertions(+), 73 deletions(-) diff --git a/packages/better-auth/src/plugins/oauth-proxy/index.ts b/packages/better-auth/src/plugins/oauth-proxy/index.ts index a39ecbe7fe..f0d12e436a 100644 --- a/packages/better-auth/src/plugins/oauth-proxy/index.ts +++ b/packages/better-auth/src/plugins/oauth-proxy/index.ts @@ -7,10 +7,11 @@ import { env } from "@better-auth/core/env"; import type { EndpointContext } from "better-call"; import { z } from "zod"; import { originCheck } from "../../api"; +import { parseJSON } from "../../client/parser"; import { symmetricDecrypt, symmetricEncrypt } from "../../crypto"; import { getOrigin } from "../../utils/url"; -function getVenderBaseURL() { +function getVendorBaseURL() { const vercel = env.VERCEL_URL ? `https://${env.VERCEL_URL}` : undefined; const netlify = env.NETLIFY_URL; const render = env.RENDER_URL; @@ -49,22 +50,35 @@ export const oAuthProxy = (opts?: OAuthProxyOptions | undefined) => { return new URL( opts?.currentURL || ctx.request?.url || - getVenderBaseURL() || + getVendorBaseURL() || ctx.context.baseURL, ); }; const checkSkipProxy = (ctx: EndpointContext) => { - // if skip proxy header is set, we don't need to proxy - const skipProxy = ctx.request?.headers.get("x-skip-oauth-proxy"); - if (skipProxy) { + // If skip proxy header is set, we don't need to proxy + const skipProxyHeader = ctx.request?.headers.get("x-skip-oauth-proxy"); + if (skipProxyHeader) { return true; } + const productionURL = opts?.productionURL || env.BETTER_AUTH_URL; - if (productionURL === ctx.context.options.baseURL) { - return true; + if (!productionURL) { + return false; } - return false; + + // Use request URL to determine current environment, not baseURL + // because baseURL is always the production URL + const currentURL = ctx.request?.url || getVendorBaseURL(); + if (!currentURL) { + return false; + } + + // Compare origins - if same, we're in production so skip proxy + const productionOrigin = getOrigin(productionURL); + const currentOrigin = getOrigin(currentURL); + + return productionOrigin === currentOrigin; }; return { @@ -127,24 +141,57 @@ export const oAuthProxy = (opts?: OAuthProxyOptions | undefined) => { ctx.context.logger.error(e); return null; }); - const error = - ctx.context.options.onAPIError?.errorURL || - `${ctx.context.options.baseURL}/api/auth/error`; if (!decryptedCookies) { + const error = + ctx.context.options.onAPIError?.errorURL || + `${ctx.context.options.baseURL}/api/auth/error`; + throw ctx.redirect( `${error}?error=OAuthProxy - Invalid cookies or secret`, ); } - const isSecureContext = resolveCurrentURL(ctx).protocol === "https:"; const prefix = ctx.context.options.advanced?.cookiePrefix || "better-auth"; - const cookieToSet = isSecureContext - ? decryptedCookies - : decryptedCookies - .replace("Secure;", "") - .replace(`__Secure-${prefix}`, prefix); - ctx.setHeader("set-cookie", cookieToSet); + const securePrefix = `__Secure-${prefix}`; + const currentURL = resolveCurrentURL(ctx); + const isSecureContext = currentURL.protocol === "https:"; + + // Process cookies: normalize for current environment + const processedCookies = decryptedCookies + .split(/,(?=\s*[^,]+=)/) + .map((cookie) => { + const parts = cookie.split(";"); + const [nameValue = "", ...attrs] = parts.map((p) => p.trim()); + const eqIndex = nameValue.indexOf("="); + + let name = eqIndex > 0 ? nameValue.slice(0, eqIndex) : nameValue; + const value = eqIndex > 0 ? nameValue.slice(eqIndex + 1) : ""; + + // Remove __Secure- prefix + if (!isSecureContext && name.includes(securePrefix)) { + name = name.replace(securePrefix, prefix); + } + + // Filter out Domain and Secure attributes + const filteredAttrs = attrs.filter((attr) => { + const lower = attr.toLowerCase(); + return ( + !lower.startsWith("domain=") && lower !== "secure" + ); + }); + + // Add Secure for HTTPS contexts + if (isSecureContext) { + filteredAttrs.push("Secure"); + } + + return filteredAttrs.length > 0 + ? `${name}=${value}; ${filteredAttrs.join("; ")}` + : `${name}=${value}`; + }); + + ctx.setHeader("set-cookie", processedCookies.join(", ")); throw ctx.redirect(ctx.query.callbackURL); }, ), @@ -161,43 +208,50 @@ export const oAuthProxy = (opts?: OAuthProxyOptions | undefined) => { handler: createAuthMiddleware(async (ctx) => { const headers = ctx.context.responseHeaders; const location = headers?.get("location"); - if (location?.includes("/oauth-proxy-callback?callbackURL")) { - if (!location.startsWith("http")) { - return; - } - const locationURL = new URL(location); - const origin = locationURL.origin; - /** - * We don't want to redirect to the proxy URL if the origin is the same - * as the current URL - */ - const productionURL = - opts?.productionURL || - ctx.context.options.baseURL || - ctx.context.baseURL; - if (origin === getOrigin(productionURL)) { - const newLocation = locationURL.searchParams.get("callbackURL"); - if (!newLocation) { - return; - } - ctx.setHeader("location", newLocation); - return; - } - const setCookies = headers?.get("set-cookie"); - - if (!setCookies) { - return; - } - const encryptedCookies = await symmetricEncrypt({ - key: ctx.context.secret, - data: setCookies, - }); - const locationWithCookies = `${location}&cookies=${encodeURIComponent( - encryptedCookies, - )}`; - ctx.setHeader("location", locationWithCookies); + if ( + !location?.includes("/oauth-proxy-callback?callbackURL") || + !location.startsWith("http") + ) { + return; } + + const productionURL = + opts?.productionURL || + ctx.context.options.baseURL || + ctx.context.baseURL; + const productionOrigin = getOrigin(productionURL); + + const locationURL = new URL(location); + const locationOrigin = locationURL.origin; + + // + // Same origin: unwrap proxy redirect to original destination + // + if (locationOrigin === productionOrigin) { + const newLocation = locationURL.searchParams.get("callbackURL"); + if (!newLocation) { + return; + } + ctx.setHeader("location", newLocation); + return; + } + + // + // Cross-origin: encrypt and forward cookies through proxy + // + const setCookies = headers?.get("set-cookie"); + if (!setCookies) { + return; + } + const encryptedCookies = await symmetricEncrypt({ + key: ctx.context.secret, + data: setCookies, + }); + const locationWithCookies = `${location}&cookies=${encodeURIComponent( + encryptedCookies, + )}`; + ctx.setHeader("location", locationWithCookies); }), }, ], @@ -207,19 +261,32 @@ export const oAuthProxy = (opts?: OAuthProxyOptions | undefined) => { return true; }, handler: createAuthMiddleware(async (ctx) => { - const skipProxy = checkSkipProxy(ctx); - if (skipProxy || ctx.path !== "/callback/:id") { + if (ctx.path !== "/callback/:id") { return; } - return { - context: { - context: { - oauthConfig: { - skipStateCookieCheck: true, - }, - }, - }, - }; + + const state = ctx.query?.state || ctx.body?.state; + if (!state) { + return; + } + + const data = + await ctx.context.internalAdapter.findVerificationValue(state); + if (!data) { + return; + } + + let parsedState: { callbackURL?: string } | undefined; + try { + parsedState = parseJSON<{ callbackURL?: string }>(data.value); + } catch { + parsedState = undefined; + } + if (!parsedState?.callbackURL?.includes("/oauth-proxy-callback")) { + return; + } + + ctx.context.oauthConfig.skipStateCookieCheck = true; }), }, { @@ -230,22 +297,25 @@ export const oAuthProxy = (opts?: OAuthProxyOptions | undefined) => { ); }, handler: createAuthMiddleware(async (ctx) => { + if (!ctx.body) { + return; + } + const skipProxy = checkSkipProxy(ctx); if (skipProxy) { return; } - const url = resolveCurrentURL(ctx); - if (!ctx.body) { - return; - } - ctx.body.callbackURL = `${url.origin}${ + + const currentURL = resolveCurrentURL(ctx); + const originalCallbackURL = + ctx.body.callbackURL || ctx.context.baseURL; + const newCallbackURL = `${currentURL.origin}${ ctx.context.options.basePath || "/api/auth" }/oauth-proxy-callback?callbackURL=${encodeURIComponent( - ctx.body.callbackURL || ctx.context.baseURL, + originalCallbackURL, )}`; - return { - context: ctx, - }; + + ctx.body.callbackURL = newCallbackURL; }), }, ],