mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 23:53:17 -05:00
fix(oauth): derive redirect URI from per-request baseURL (#10127)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user