fix(oauth)!: derive redirect URI from per-request baseURL via declarative callbackPath

Multi-host deployments configured with `baseURL: { allowedHosts: [...] }`
now produce absolute `redirect_uri` values for generic-OAuth providers. Auth0
and other strict authorization servers previously rejected the authorize step
because `genericOAuth` captured `ctx.baseURL` at init time, which is the empty
sentinel under dynamic config.

Every `OAuthProvider` now declares `callbackPath: string`. Endpoints compose
`redirectURI = ctx.context.baseURL + provider.callbackPath` in the
social-sign-in, callback, link-account, and oauth-proxy handlers, removing the
init-time `ctx.baseURL` read across the OAuth surface. Built-in providers use
`/callback/<id>`; generic-OAuth uses `/oauth2/callback/<id>`.

The interface change is invisible to app developers. Third-party plugin
authors who construct their own `OAuthProvider` must add `callbackPath` to
their factory.

Resolves #9593. Partial fix for #4151.

Co-Authored-By: thaoms <2323270+thaoms@users.noreply.github.com>
This commit is contained in:
Gustavo Valverde
2026-05-31 10:09:06 +01:00
co-authored by thaoms
parent 8dcddf786c
commit 2c29c1e919
43 changed files with 194 additions and 4 deletions
@@ -0,0 +1,22 @@
---
"better-auth": minor
"@better-auth/core": minor
---
Derive OAuth `redirect_uri` from the per-request `baseURL` so social and generic-OAuth sign-in work end-to-end under `baseURL: { allowedHosts: [...] }`.
Before this change, the `genericOAuth` plugin captured `ctx.baseURL` at init time and sent providers a relative URI like `/oauth2/callback/<id>` whenever a dynamic baseURL was configured. Auth0 and other strict providers rejected the authorize request. Built-in social providers were unaffected because their endpoints already resolved per-request.
The fix is structural rather than a string patch. Every `OAuthProvider` now declares a `callbackPath: string` that describes its callback path under the per-request `baseURL`. The framework composes `redirectURI = ctx.context.baseURL + provider.callbackPath` inside the sign-in, callback, link-account, and oauth-proxy endpoints. The init-time `ctx.baseURL` is no longer read by any OAuth provider, which removes the bug class.
Resolves #9593. Partial fix for #4151 — `redirect_uri` composition now respects `allowedHosts` for both built-in providers and generic-OAuth providers.
BREAKING CHANGE: Custom `OAuthProvider` implementations must declare `callbackPath`. End-user `betterAuth({...})` config is unaffected; only authors of custom OAuth provider plugins need to update their factories. Convention:
```ts
// built-in lane
callbackPath: `/callback/<provider-id>`,
// generic-oauth lane
callbackPath: `/oauth2/callback/<provider-id>`,
```
@@ -378,7 +378,7 @@ export const linkSocialAccount = createAuthEndpoint(
const url = await provider.createAuthorizationURL({
state: state.state,
codeVerifier: state.codeVerifier,
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
redirectURI: `${c.context.baseURL}${provider.callbackPath}`,
scopes: c.body.scopes,
loginHint: c.body.loginHint,
additionalParams: c.body.additionalParams,
@@ -172,7 +172,7 @@ export const callbackOAuth = createAuthEndpoint(
code: code,
codeVerifier,
deviceId: device_id,
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
redirectURI: `${c.context.baseURL}${provider.callbackPath}`,
});
} catch (e) {
c.context.logger.error("", e);
@@ -349,7 +349,7 @@ export const signInSocial = <O extends BetterAuthOptions>() =>
const url = await provider.createAuthorizationURL({
state,
codeVerifier,
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
redirectURI: `${c.context.baseURL}${provider.callbackPath}`,
scopes: c.body.scopes,
loginHint: c.body.loginHint,
additionalParams: c.body.additionalParams,
@@ -2906,3 +2906,124 @@ describe("oauth2", async () => {
});
});
});
/**
* @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(dynamicServer.issuer.url?.split(":")[2]!);
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/oauth2/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/oauth2/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");
});
});
@@ -252,6 +252,7 @@ export const genericOAuth = <const ID extends string>(
genericProviders.push({
id: c.providerId,
name: c.name ?? c.providerId,
callbackPath: `/oauth2/callback/${c.providerId}`,
issuer,
allowIdpInitiated: c.allowIdpInitiated,
createAuthorizationURL(data) {
@@ -436,7 +436,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}${provider.callbackPath}`,
});
} catch (e) {
ctx.context.logger.error(
@@ -28,6 +28,17 @@ export interface OAuthProvider<
O extends Record<string, any> = Partial<ProviderOptions>,
> {
id: LiteralString;
/**
* Path under the resolved per-request `baseURL` where this provider's
* OAuth callback handler is mounted. Endpoints compose
* `redirectURI = ctx.context.baseURL + callbackPath` per request, so the
* provider must not hardcode an origin or `baseURL` here.
*
* Built-in social providers use `/callback/<id>`. Plugin-provided OAuth
* providers (e.g. generic-oauth) use their plugin's own lane, such as
* `/oauth2/callback/<id>`.
*/
callbackPath: string;
createAuthorizationURL: (data: {
state: string;
codeVerifier: string;
@@ -99,6 +99,7 @@ export const apple = (options: AppleOptions) => {
const tokenEndpoint = "https://appleid.apple.com/auth/token";
return {
id: "apple",
callbackPath: "/callback/apple",
name: "Apple",
async createAuthorizationURL({
state,
@@ -33,6 +33,7 @@ export const atlassian = (options: AtlassianOptions) => {
const tokenEndpoint = "https://auth.atlassian.com/oauth/token";
return {
id: "atlassian",
callbackPath: "/callback/atlassian",
name: "Atlassian",
async createAuthorizationURL({
@@ -72,6 +72,7 @@ export const cognito = (options: CognitoOptions) => {
return {
id: "cognito",
callbackPath: "/callback/cognito",
name: "Cognito",
async createAuthorizationURL({
state,
@@ -87,6 +87,7 @@ export const discord = (options: DiscordOptions) => {
const tokenEndpoint = "https://discord.com/api/oauth2/token";
return {
id: "discord",
callbackPath: "/callback/discord",
name: "Discord",
createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) {
const _scopes = options.disableDefaultScope ? [] : ["identify", "email"];
@@ -30,6 +30,7 @@ export const dropbox = (options: DropboxOptions) => {
return {
id: "dropbox",
callbackPath: "/callback/dropbox",
name: "Dropbox",
createAuthorizationURL: async ({
state,
@@ -42,6 +42,7 @@ export interface FacebookOptions extends ProviderOptions<FacebookProfile> {
export const facebook = (options: FacebookOptions) => {
return {
id: "facebook",
callbackPath: "/callback/facebook",
name: "Facebook",
async createAuthorizationURL({
state,
@@ -23,6 +23,7 @@ export const figma = (options: FigmaOptions) => {
const tokenEndpoint = "https://api.figma.com/v1/oauth/token";
return {
id: "figma",
callbackPath: "/callback/figma",
name: "Figma",
async createAuthorizationURL({
state,
@@ -62,6 +62,7 @@ export const github = (options: GithubOptions) => {
const tokenEndpoint = "https://github.com/login/oauth/access_token";
return {
id: "github",
callbackPath: "/callback/github",
name: "GitHub",
createAuthorizationURL({
state,
@@ -80,6 +80,7 @@ export const gitlab = (options: GitlabOptions) => {
const issuerName = "Gitlab";
return {
id: issuerId,
callbackPath: `/callback/${issuerId}`,
name: issuerName,
createAuthorizationURL: async ({
state,
@@ -56,6 +56,7 @@ export interface GoogleOptions extends ProviderOptions<GoogleProfile> {
export const google = (options: GoogleOptions) => {
return {
id: "google",
callbackPath: "/callback/google",
name: "Google",
async createAuthorizationURL({
state,
@@ -46,6 +46,7 @@ export const huggingface = (options: HuggingFaceOptions) => {
const tokenEndpoint = "https://huggingface.co/oauth/token";
return {
id: "huggingface",
callbackPath: "/callback/huggingface",
name: "Hugging Face",
createAuthorizationURL({
state,
@@ -105,6 +105,7 @@ export const kakao = (options: KakaoOptions) => {
const tokenEndpoint = "https://kauth.kakao.com/oauth/token";
return {
id: "kakao",
callbackPath: "/callback/kakao",
name: "Kakao",
createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) {
const _scopes = options.disableDefaultScope
@@ -32,6 +32,7 @@ export interface KickOptions extends ProviderOptions<KickProfile> {
export const kick = (options: KickOptions) => {
return {
id: "kick",
callbackPath: "/callback/kick",
name: "Kick",
createAuthorizationURL({
state,
@@ -49,6 +49,7 @@ export const line = (options: LineOptions) => {
return {
id: "line",
callbackPath: "/callback/line",
name: "LINE",
async createAuthorizationURL({
state,
@@ -30,6 +30,7 @@ export const linear = (options: LinearOptions) => {
const tokenEndpoint = "https://api.linear.app/oauth/token";
return {
id: "linear",
callbackPath: "/callback/linear",
name: "Linear",
createAuthorizationURL({
state,
@@ -31,6 +31,7 @@ export const linkedin = (options: LinkedInOptions) => {
return {
id: "linkedin",
callbackPath: "/callback/linkedin",
name: "Linkedin",
createAuthorizationURL: async ({
state,
@@ -148,6 +148,7 @@ export const microsoft = (options: MicrosoftOptions) => {
const tokenEndpoint = `${authority}/${tenant}/oauth2/v2.0/token`;
return {
id: "microsoft",
callbackPath: "/callback/microsoft",
name: "Microsoft EntraID",
createAuthorizationURL(data) {
// Microsoft Entra supports public clients (SPA / native apps with
@@ -43,6 +43,7 @@ export const naver = (options: NaverOptions) => {
const tokenEndpoint = "https://nid.naver.com/oauth2.0/token";
return {
id: "naver",
callbackPath: "/callback/naver",
name: "Naver",
createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) {
const _scopes = options.disableDefaultScope ? [] : ["profile", "email"];
@@ -27,6 +27,7 @@ export const notion = (options: NotionOptions) => {
const tokenEndpoint = "https://api.notion.com/v1/oauth/token";
return {
id: "notion",
callbackPath: "/callback/notion",
name: "Notion",
createAuthorizationURL({
state,
@@ -35,6 +35,7 @@ export const paybin = (options: PaybinOptions) => {
return {
id: "paybin",
callbackPath: "/callback/paybin",
name: "Paybin",
async createAuthorizationURL({
state,
@@ -77,6 +77,7 @@ export const paypal = (options: PayPalOptions) => {
return {
id: "paypal",
callbackPath: "/callback/paypal",
name: "PayPal",
async createAuthorizationURL({
state,
@@ -36,6 +36,7 @@ export const polar = (options: PolarOptions) => {
const tokenEndpoint = "https://api.polar.sh/v1/oauth2/token";
return {
id: "polar",
callbackPath: "/callback/polar",
name: "Polar",
createAuthorizationURL({
state,
@@ -28,6 +28,7 @@ export interface RailwayOptions extends ProviderOptions<RailwayProfile> {
export const railway = (options: RailwayOptions) => {
return {
id: "railway",
callbackPath: "/callback/railway",
name: "Railway",
createAuthorizationURL({
state,
@@ -24,6 +24,7 @@ export interface RedditOptions extends ProviderOptions<RedditProfile> {
export const reddit = (options: RedditOptions) => {
return {
id: "reddit",
callbackPath: "/callback/reddit",
name: "Reddit",
createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) {
const _scopes = options.disableDefaultScope ? [] : ["identity"];
@@ -40,6 +40,7 @@ export const roblox = (options: RobloxOptions) => {
const tokenEndpoint = "https://apis.roblox.com/oauth/v1/token";
return {
id: "roblox",
callbackPath: "/callback/roblox",
name: "Roblox",
createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) {
const _scopes = options.disableDefaultScope ? [] : ["openid", "profile"];
@@ -62,6 +62,7 @@ export const salesforce = (options: SalesforceOptions) => {
return {
id: "salesforce",
callbackPath: "/callback/salesforce",
name: "Salesforce",
async createAuthorizationURL({
@@ -45,6 +45,7 @@ export const slack = (options: SlackOptions) => {
const tokenEndpoint = "https://slack.com/api/openid.connect.token";
return {
id: "slack",
callbackPath: "/callback/slack",
name: "Slack",
createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) {
const _scopes = options.disableDefaultScope
@@ -23,6 +23,7 @@ export const spotify = (options: SpotifyOptions) => {
const tokenEndpoint = "https://accounts.spotify.com/api/token";
return {
id: "spotify",
callbackPath: "/callback/spotify",
name: "Spotify",
createAuthorizationURL({
state,
@@ -134,6 +134,7 @@ export const tiktok = (options: TiktokOptions) => {
const tokenEndpoint = "https://open.tiktokapis.com/v2/oauth/token/";
return {
id: "tiktok",
callbackPath: "/callback/tiktok",
name: "TikTok",
createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) {
const _scopes = options.disableDefaultScope ? [] : ["user.info.profile"];
@@ -41,6 +41,7 @@ export const twitch = (options: TwitchOptions) => {
const tokenEndpoint = "https://id.twitch.tv/oauth2/token";
return {
id: "twitch",
callbackPath: "/callback/twitch",
name: "Twitch",
createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) {
const _scopes = options.disableDefaultScope
@@ -107,6 +107,7 @@ export const twitter = (options: TwitterOption) => {
const tokenEndpoint = "https://api.x.com/2/oauth2/token";
return {
id: "twitter",
callbackPath: "/callback/twitter",
name: "Twitter",
createAuthorizationURL(data) {
const _scopes = options.disableDefaultScope
@@ -19,6 +19,7 @@ export interface VercelOptions extends ProviderOptions<VercelProfile> {
export const vercel = (options: VercelOptions) => {
return {
id: "vercel",
callbackPath: "/callback/vercel",
name: "Vercel",
createAuthorizationURL({
state,
+1
View File
@@ -29,6 +29,7 @@ export const vk = (options: VkOption) => {
const tokenEndpoint = "https://id.vk.com/oauth2/auth";
return {
id: "vk",
callbackPath: "/callback/vk",
name: "VK",
async createAuthorizationURL({
state,
@@ -58,6 +58,7 @@ export interface WeChatOptions extends ProviderOptions<WeChatProfile> {
export const wechat = (options: WeChatOptions) => {
return {
id: "wechat",
callbackPath: "/callback/wechat",
name: "WeChat",
createAuthorizationURL({ state, scopes, redirectURI, additionalParams }) {
const _scopes = options.disableDefaultScope ? [] : ["snsapi_login"];
@@ -151,6 +151,7 @@ export const zoom = (userOptions: ZoomOptions) => {
return {
id: "zoom",
callbackPath: "/callback/zoom",
name: "Zoom",
createAuthorizationURL: async ({
state,