diff --git a/.changeset/oauth-callback-path-declarative.md b/.changeset/oauth-callback-path-declarative.md new file mode 100644 index 0000000000..82eb7f0db0 --- /dev/null +++ b/.changeset/oauth-callback-path-declarative.md @@ -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/` route. Set `callbackPath` only for custom callback routes. diff --git a/packages/better-auth/src/api/routes/account.ts b/packages/better-auth/src/api/routes/account.ts index b8314d9bbb..4991e93b5d 100644 --- a/packages/better-auth/src/api/routes/account.ts +++ b/packages/better-auth/src/api/routes/account.ts @@ -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, diff --git a/packages/better-auth/src/api/routes/callback.ts b/packages/better-auth/src/api/routes/callback.ts index b950af4226..ef9830d114 100644 --- a/packages/better-auth/src/api/routes/callback.ts +++ b/packages/better-auth/src/api/routes/callback.ts @@ -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); diff --git a/packages/better-auth/src/api/routes/sign-in.ts b/packages/better-auth/src/api/routes/sign-in.ts index 5c2ffff9f0..b7a516d40a 100644 --- a/packages/better-auth/src/api/routes/sign-in.ts +++ b/packages/better-auth/src/api/routes/sign-in.ts @@ -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 = () => 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, diff --git a/packages/better-auth/src/oauth2/utils.ts b/packages/better-auth/src/oauth2/utils.ts index f697cd2bc1..d872d90f87 100644 --- a/packages/better-auth/src/oauth2/utils.ts +++ b/packages/better-auth/src/oauth2/utils.ts @@ -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}`; +} diff --git a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts index 0e011a2723..c6ce334b87 100644 --- a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts +++ b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts @@ -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 }, + 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"); + }); +}); diff --git a/packages/better-auth/src/plugins/oauth-proxy/index.ts b/packages/better-auth/src/plugins/oauth-proxy/index.ts index c3d77ffeb2..43d9c7aa85 100644 --- a/packages/better-auth/src/plugins/oauth-proxy/index.ts +++ b/packages/better-auth/src/plugins/oauth-proxy/index.ts @@ -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 = (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( diff --git a/packages/core/src/oauth2/oauth-provider.ts b/packages/core/src/oauth2/oauth-provider.ts index e889eeaa50..b78ec88086 100644 --- a/packages/core/src/oauth2/oauth-provider.ts +++ b/packages/core/src/oauth2/oauth-provider.ts @@ -94,6 +94,17 @@ export interface OAuthProvider< O extends Record = Partial, > { 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/` 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;