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>
This commit is contained in:
Taesu
2025-11-10 15:11:32 -08:00
committed by GitHub
co-authored by Bereket Engida cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Copilot
parent e843e51a2a
commit 235b09484b
@@ -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<string, any>) => {
// 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;
}),
},
],