fix(oauth): derive redirect URI from per-request baseURL (#10127)

This commit is contained in:
Gustavo Valverde
2026-06-18 07:26:59 -07:00
committed by GitHub
parent a83152e2e8
commit 7c7313c818
8 changed files with 176 additions and 8 deletions
@@ -0,0 +1,8 @@
---
"better-auth": minor
"@better-auth/core": minor
---
OAuth sign-in, account linking, callback, and proxy flows now build `redirect_uri` from the current request base URL when `baseURL.allowedHosts` is configured. Built-in social providers and generic OAuth providers now use the resolved request host for redirects in multi-host deployments.
Custom `OAuthProvider` implementations can omit `callbackPath` when using the shared `/callback/<provider-id>` route. Set `callbackPath` only for custom callback routes.
@@ -21,7 +21,11 @@ import { parseAccountOutput } from "../../db/schema";
import { missingEmailLogMessage } from "../../oauth2/errors";
import { applyUpdateUserInfoOnLink } from "../../oauth2/link-account";
import { generateIdTokenNonce, generateState } from "../../oauth2/state";
import { decryptOAuthToken, setTokenUtil } from "../../oauth2/utils";
import {
decryptOAuthToken,
getOAuthCallbackPath,
setTokenUtil,
} from "../../oauth2/utils";
import {
freshSessionMiddleware,
getSessionFromCtx,
@@ -384,7 +388,7 @@ export const linkSocialAccount = createAuthEndpoint(
state: state.state,
codeVerifier: state.codeVerifier,
idTokenNonce,
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
redirectURI: `${c.context.baseURL}${getOAuthCallbackPath(provider)}`,
scopes: c.body.scopes,
loginHint: c.body.loginHint,
additionalParams: c.body.additionalParams,
@@ -17,7 +17,7 @@ import {
generateState,
parseState,
} from "../../oauth2/state";
import { setTokenUtil } from "../../oauth2/utils";
import { getOAuthCallbackPath, setTokenUtil } from "../../oauth2/utils";
import { HIDE_METADATA } from "../../utils/hide-metadata";
import { isAPIError } from "../../utils/is-api-error";
import { assertValidUserInfo } from "../../utils/validate-user-info";
@@ -106,7 +106,7 @@ export const callbackOAuth = createAuthEndpoint(
state: freshState,
codeVerifier,
idTokenNonce,
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
redirectURI: `${c.context.baseURL}${getOAuthCallbackPath(provider)}`,
});
throw c.redirect(authUrl.toString());
}
@@ -191,7 +191,7 @@ export const callbackOAuth = createAuthEndpoint(
code: code,
codeVerifier,
deviceId: device_id,
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
redirectURI: `${c.context.baseURL}${getOAuthCallbackPath(provider)}`,
});
} catch (e) {
c.context.logger.error("", e);
@@ -17,6 +17,7 @@ import {
OAUTH_CALLBACK_ERROR_CODES,
} from "../../oauth2/errors";
import { handleOAuthUserInfo } from "../../oauth2/link-account";
import { getOAuthCallbackPath } from "../../oauth2/utils";
import { generateIdTokenNonce, generateState } from "../../utils";
import { formCsrfMiddleware } from "../middlewares/origin-check";
import { createEmailVerificationToken } from "./email-verification";
@@ -367,7 +368,7 @@ export const signInSocial = <O extends BetterAuthOptions>() =>
state,
codeVerifier,
idTokenNonce,
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
redirectURI: `${c.context.baseURL}${getOAuthCallbackPath(provider)}`,
scopes: c.body.scopes,
loginHint: c.body.loginHint,
additionalParams: c.body.additionalParams,
+13 -1
View File
@@ -1,4 +1,4 @@
import type { AuthContext } from "@better-auth/core";
import type { AuthContext, LiteralString } from "@better-auth/core";
import { symmetricDecrypt, symmetricEncrypt } from "../crypto";
/**
@@ -35,3 +35,15 @@ export function setTokenUtil(
}
return token;
}
export function getOAuthCallbackPath(provider: {
id: LiteralString;
callbackPath?: string | undefined;
}) {
if (!provider.callbackPath) {
return `/callback/${provider.id}`;
}
return provider.callbackPath.startsWith("/")
? provider.callbackPath
: `/${provider.callbackPath}`;
}
@@ -15,6 +15,7 @@ import { createAuthClient } from "../../client";
import { getAwaitableValue } from "../../context/helpers";
import { parseSetCookieHeader } from "../../cookies";
import { symmetricDecodeJWT } from "../../crypto";
import { getOAuthCallbackPath } from "../../oauth2/utils";
import { getTestInstance } from "../../test-utils/test-instance";
import { genericOAuth } from ".";
import { auth0 } from "./providers/auth0";
@@ -4615,3 +4616,133 @@ describe("oauth2", async () => {
expect(accounts).toHaveLength(0);
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/4151
* @see https://github.com/better-auth/better-auth/issues/9593
*/
describe("redirect_uri composition under dynamic baseURL", async () => {
const dynamicServer = new OAuth2Server();
await dynamicServer.start();
const dynamicPort = Number(new URL(dynamicServer.issuer.url!).port);
afterAll(async () => {
await dynamicServer.stop();
});
beforeAll(async () => {
await dynamicServer.issuer.keys.generate("RS256");
});
async function getAuthorizeRedirectUri(
auth: { handler: (request: Request) => Promise<Response> },
signInPath: string,
host: string,
providerId: string,
) {
const response = await auth.handler(
new Request(`http://${host}${signInPath}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
provider: providerId,
callbackURL: "/dashboard",
disableRedirect: true,
}),
}),
);
const json = (await response.json()) as { url?: string };
expect(json.url).toBeDefined();
const authUrl = new URL(json.url!);
return authUrl.searchParams.get("redirect_uri");
}
it("composes an absolute redirect_uri for a generic-oauth provider at the default basePath", async () => {
const { auth } = await getTestInstance({
baseURL: { allowedHosts: ["localhost:3000"] },
plugins: [
genericOAuth({
config: [
{
providerId: "dynamic-oauth-test",
discoveryUrl: `http://localhost:${dynamicPort}/.well-known/openid-configuration`,
clientId: "test-client-id",
clientSecret: "test-client-secret",
},
],
}),
],
});
const redirectUri = await getAuthorizeRedirectUri(
auth,
"/api/auth/sign-in/social",
"localhost:3000",
"dynamic-oauth-test",
);
expect(redirectUri).toBe(
"http://localhost:3000/api/auth/callback/dynamic-oauth-test",
);
});
it("composes an absolute redirect_uri for a generic-oauth provider at a custom basePath", async () => {
const { auth } = await getTestInstance({
baseURL: { allowedHosts: ["localhost:3000"] },
basePath: "/auth",
plugins: [
genericOAuth({
config: [
{
providerId: "dynamic-oauth-test",
discoveryUrl: `http://localhost:${dynamicPort}/.well-known/openid-configuration`,
clientId: "test-client-id",
clientSecret: "test-client-secret",
},
],
}),
],
});
const redirectUri = await getAuthorizeRedirectUri(
auth,
"/auth/sign-in/social",
"localhost:3000",
"dynamic-oauth-test",
);
expect(redirectUri).toBe(
"http://localhost:3000/auth/callback/dynamic-oauth-test",
);
});
it("composes an absolute redirect_uri for a built-in social provider", async () => {
const { auth } = await getTestInstance({
baseURL: { allowedHosts: ["localhost:3000"] },
socialProviders: {
google: {
clientId: "google-client-id",
clientSecret: "google-client-secret",
},
},
});
const redirectUri = await getAuthorizeRedirectUri(
auth,
"/api/auth/sign-in/social",
"localhost:3000",
"google",
);
expect(redirectUri).toBe("http://localhost:3000/api/auth/callback/google");
});
it("normalizes custom callbackPath values without a leading slash", () => {
expect(
getOAuthCallbackPath({
id: "custom-oauth-test",
callbackPath: "callback/custom-oauth-test",
}),
).toBe("/callback/custom-oauth-test");
});
});
@@ -17,6 +17,7 @@ import { parseSetCookieHeader } from "../../cookies/cookie-utils";
import { symmetricDecrypt, symmetricEncrypt } from "../../crypto";
import { redirectOnError } from "../../oauth2/errors";
import { handleOAuthUserInfo } from "../../oauth2/link-account";
import { getOAuthCallbackPath } from "../../oauth2/utils";
import type { StateData } from "../../state";
import { parseGenericState } from "../../state";
import type { Account, User } from "../../types";
@@ -447,7 +448,7 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
tokens = await provider.validateAuthorizationCode({
code,
codeVerifier: stateData.codeVerifier,
redirectURI: `${ctx.context.baseURL}/callback/${provider.id}`,
redirectURI: `${ctx.context.baseURL}${getOAuthCallbackPath(provider)}`,
});
} catch (e) {
ctx.context.logger.error(
@@ -94,6 +94,17 @@ export interface OAuthProvider<
O extends Record<string, any> = Partial<ProviderOptions>,
> {
id: LiteralString;
/**
* Optional path under the resolved per-request `baseURL` where this
* provider's OAuth callback handler is mounted. Providers that use the
* shared `/callback/<id>` route can omit this.
*
* Custom paths must start with `/`.
*
* Endpoints compose `redirectURI = ctx.context.baseURL + callbackPath` per
* request, so the provider must not hardcode an origin or `baseURL` here.
*/
callbackPath?: string | undefined;
createAuthorizationURL: (data: {
state: string;
codeVerifier: string;