fix: redirect hook rejections to errorCallbackURL across auth callback flows (#9702)

This commit is contained in:
Taesu
2026-05-22 00:14:48 +00:00
committed by GitHub
parent 5190c2658f
commit 23dbe1ad0e
11 changed files with 449 additions and 99 deletions
@@ -0,0 +1,6 @@
---
"@better-auth/sso": patch
"better-auth": patch
---
Banned users signing in with an OAuth provider now redirect to the `errorCallbackURL` passed to `signIn.social`, with `?error=BANNED_USER&error_description=<message>` in the query string. Previously the redirect went to the auth server's default error page with `?error=banned`, which broke multi-domain deployments where the auth host and frontend host differ. The `oauth-proxy`, SSO OIDC, and SAML callbacks now also redirect hook rejections to the error URL (previously returned JSON 403), and `oauth-proxy` URL-encodes the `error` query value across all its redirects.
+23 -14
View File
@@ -9,6 +9,7 @@ import { handleOAuthUserInfo } from "../../oauth2/link-account";
import { parseState } from "../../oauth2/state";
import { setTokenUtil } from "../../oauth2/utils";
import { HIDE_METADATA } from "../../utils/hide-metadata";
import { isAPIError } from "../../utils/is-api-error";
const schema = z.object({
code: z.string().optional(),
@@ -253,20 +254,28 @@ export const callbackOAuth = createAuthEndpoint(
...tokens,
scope: tokens.scopes?.join(","),
};
const result = await handleOAuthUserInfo(c, {
userInfo: {
...userInfo,
id: providerAccountId,
email: userInfo.email,
name: userInfo.name || "",
},
account: accountData,
callbackURL,
disableSignUp:
(provider.disableImplicitSignUp && !requestSignUp) ||
provider.options?.disableSignUp,
overrideUserInfo: provider.options?.overrideUserInfoOnSignIn,
});
let result: Awaited<ReturnType<typeof handleOAuthUserInfo>>;
try {
result = await handleOAuthUserInfo(c, {
userInfo: {
...userInfo,
id: providerAccountId,
email: userInfo.email,
name: userInfo.name || "",
},
account: accountData,
callbackURL,
disableSignUp:
(provider.disableImplicitSignUp && !requestSignUp) ||
provider.options?.disableSignUp,
overrideUserInfo: provider.options?.overrideUserInfoOnSignIn,
});
} catch (e) {
if (isAPIError(e) && e.body?.code) {
return redirectOnError(e.body.code, e.body.message);
}
throw e;
}
if (result.error) {
c.context.logger.error(result.error.split(" ").join("_"));
return redirectOnError(result.error.split(" ").join("_"));
@@ -1,5 +1,6 @@
import { BetterAuthError } from "@better-auth/core/error";
import type { GoogleProfile } from "@better-auth/core/social-providers";
import { exportJWK, generateKeyPair, SignJWT } from "jose";
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import {
@@ -75,6 +76,7 @@ describe("Admin plugin", async () => {
customFetchImpl,
} = await getTestInstance(
{
trustedOrigins: ["https://frontend.example.com"],
plugins: [
admin({
bannedUserMessage: "Custom banned user message",
@@ -620,7 +622,44 @@ describe("Admin plugin", async () => {
},
});
expect(errorLocation).toBeDefined();
expect(errorLocation).toContain("error=banned");
expect(errorLocation).toContain("error=BANNED_USER");
});
it("should redirect banned social sign-in to a cross-origin errorCallbackURL", async () => {
const errorCallbackURL = "https://frontend.example.com/auth-error";
const headers = new Headers();
const res = await client.signIn.social(
{
provider: "google",
errorCallbackURL,
},
{
throw: true,
onSuccess: cookieSetter(headers),
},
);
const state = new URL(res.url!).searchParams.get("state");
let errorLocation: string | null = null;
await client.$fetch("/callback/google", {
query: {
state,
code: "test",
},
headers,
method: "GET",
onError(context) {
expect(context.response.status).toBe(302);
errorLocation = context.response.headers.get("location");
},
});
expect(errorLocation).toBeTruthy();
const url = new URL(errorLocation!);
expect(url.origin).toBe("https://frontend.example.com");
expect(`${url.origin}${url.pathname}`).toBe(errorCallbackURL);
expect(url.searchParams.get("error")).toBe("BANNED_USER");
expect(url.searchParams.get("error_description")).toBe(
"Custom banned user message",
);
});
it("should change banned user message", async () => {
@@ -1935,3 +1974,67 @@ describe("edge cases: userId validation", async () => {
).rejects.toThrow("user not found");
});
});
describe("Admin plugin id-token sign-in", async () => {
const googleKeyPair = await generateKeyPair("RS256");
const googleJwk = await exportJWK(googleKeyPair.publicKey);
const googleKid = "test-admin-google-kid";
googleJwk.kid = googleKid;
googleJwk.alg = "RS256";
googleJwk.use = "sig";
const clientId = "admin-idtoken-client.googleusercontent.com";
const signIdToken = (email: string) =>
new SignJWT({
email,
email_verified: true,
name: "Banned ID Token User",
sub: "banned-google-sub",
})
.setProtectedHeader({ alg: "RS256", kid: googleKid })
.setIssuedAt()
.setIssuer("https://accounts.google.com")
.setAudience(clientId)
.setExpirationTime("1h")
.sign(googleKeyPair.privateKey);
beforeAll(() => {
server.use(
http.get("https://www.googleapis.com/oauth2/v3/certs", () =>
HttpResponse.json({ keys: [googleJwk] }),
),
);
});
it("should return BANNED_USER 403 JSON for id-token sign-in by banned user", async () => {
const { client } = await getTestInstance(
{
plugins: [admin({ bannedUserMessage: "Custom banned user message" })],
socialProviders: {
google: { clientId, clientSecret: "test-secret" },
},
databaseHooks: {
user: {
create: {
before: async (user) => ({
data: { ...user, emailVerified: true, banned: true },
}),
},
},
},
},
{ disableTestUser: true },
);
const idToken = await signIdToken("banned-idtoken@test.com");
const res = await client.signIn.social({
provider: "google",
idToken: { token: idToken },
});
expect(res.error?.status).toBe(403);
expect(res.error?.code).toBe("BANNED_USER");
expect(res.error?.message).toBe("Custom banned user message");
});
});
@@ -113,19 +113,6 @@ export const admin = <O extends AdminOptions>(options?: O | undefined) => {
return;
}
if (
ctx &&
(ctx.path.startsWith("/callback") ||
ctx.path.startsWith("/oauth2/callback"))
) {
const redirectURI =
ctx.context.options.onAPIError?.errorURL ||
`${ctx.context.baseURL}/error`;
throw ctx.redirect(
`${redirectURI}?error=banned&error_description=${opts.bannedUserMessage}`,
);
}
throw APIError.from("FORBIDDEN", {
message: opts.bannedUserMessage,
code: "BANNED_USER",
@@ -1,5 +1,6 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { runWithEndpointContext } from "@better-auth/core/context";
import { APIError } from "@better-auth/core/error";
import { betterFetch } from "@better-fetch/fetch";
import { OAuth2Server } from "oauth2-mock-server";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
@@ -486,6 +487,82 @@ describe("oauth2", async () => {
);
});
it("should redirect to cross-origin errorCallbackURL when a session hook throws APIError", async () => {
server.service.once("beforeUserinfo", (userInfoResponse) => {
userInfoResponse.body = {
email: "hook-reject@test.com",
name: "Hook Reject User",
sub: "hook-reject",
picture: "https://test.com/picture.png",
email_verified: true,
};
userInfoResponse.statusCode = 200;
});
const { customFetchImpl, cookieSetter } = await getTestInstance(
{
trustedOrigins: ["https://frontend.example.com"],
plugins: [
genericOAuth({
config: [
{
providerId: "test-hook-reject",
discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`,
clientId,
clientSecret,
pkce: true,
},
],
}),
],
databaseHooks: {
user: {
create: {
before: async (user) => ({
data: { ...user, emailVerified: true },
}),
},
},
session: {
create: {
before: async () => {
throw APIError.from("FORBIDDEN", {
code: "HOOK_REJECTED",
message: "Session hook rejected this user",
});
},
},
},
},
},
{ disableTestUser: true },
);
const authClient = createAuthClient({
plugins: [genericOAuthClient()],
baseURL: "http://localhost:3000",
fetchOptions: { customFetchImpl },
});
const headers = new Headers();
const res = await authClient.signIn.oauth2({
providerId: "test-hook-reject",
callbackURL: "https://frontend.example.com/dashboard",
errorCallbackURL: "https://frontend.example.com/auth-error",
fetchOptions: { onSuccess: cookieSetter(headers) },
});
const { callbackURL } = await simulateOAuthFlow(
res.data?.url || "",
headers,
customFetchImpl,
);
const url = new URL(callbackURL);
expect(url.origin).toBe("https://frontend.example.com");
expect(url.pathname).toBe("/auth-error");
expect(url.searchParams.get("error")).toBe("HOOK_REJECTED");
expect(url.searchParams.get("error_description")).toBe(
"Session hook rejected this user",
);
});
it("should create user when sign ups are disabled and sign up is requested", async () => {
server.service.once("beforeUserinfo", (userInfoResponse) => {
userInfoResponse.body = {
@@ -17,6 +17,7 @@ import { generateState, parseState } from "../../oauth2/state";
import { setTokenUtil } from "../../oauth2/utils";
import type { User } from "../../types";
import { HIDE_METADATA } from "../../utils";
import { isAPIError } from "../../utils/is-api-error";
import { GENERIC_OAUTH_ERROR_CODES } from "./error-codes";
import type { GenericOAuthOptions } from "./types";
@@ -315,17 +316,15 @@ export const oAuth2Callback = (options: GenericOAuthOptions) =>
} = parsedState;
const code = ctx.query.code;
function redirectOnError(error: string) {
function redirectOnError(error: string, description?: string) {
const defaultErrorURL =
ctx.context.options.onAPIError?.errorURL ||
`${ctx.context.baseURL}/error`;
let url = errorURL || defaultErrorURL;
if (url.includes("?")) {
url = `${url}&error=${encodeURIComponent(error)}`;
} else {
url = `${url}?error=${encodeURIComponent(error)}`;
}
throw ctx.redirect(url);
const baseURL = errorURL || defaultErrorURL;
const params = new URLSearchParams({ error });
if (description) params.set("error_description", description);
const sep = baseURL.includes("?") ? "&" : "?";
throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
}
let finalTokenUrl = providerConfig.tokenUrl;
@@ -514,20 +513,28 @@ export const oAuth2Callback = (options: GenericOAuthOptions) =>
throw ctx.redirect(toRedirectTo);
}
const result = await handleOAuthUserInfo(ctx, {
userInfo,
account: {
providerId: providerConfig.providerId,
accountId: userInfo.id,
...tokens,
scope: tokens.scopes?.join(","),
},
callbackURL: callbackURL,
disableSignUp:
(providerConfig.disableImplicitSignUp && !requestSignUp) ||
providerConfig.disableSignUp,
overrideUserInfo: providerConfig.overrideUserInfo,
});
let result: Awaited<ReturnType<typeof handleOAuthUserInfo>>;
try {
result = await handleOAuthUserInfo(ctx, {
userInfo,
account: {
providerId: providerConfig.providerId,
accountId: userInfo.id,
...tokens,
scope: tokens.scopes?.join(","),
},
callbackURL: callbackURL,
disableSignUp:
(providerConfig.disableImplicitSignUp && !requestSignUp) ||
providerConfig.disableSignUp,
overrideUserInfo: providerConfig.overrideUserInfo,
});
} catch (e) {
if (isAPIError(e) && e.body?.code) {
return redirectOnError(e.body.code, e.body.message);
}
throw e;
}
if (result.error) {
return redirectOnError(result.error.split(" ").join("_"));
@@ -19,6 +19,7 @@ import { handleOAuthUserInfo } from "../../oauth2/link-account";
import type { StateData } from "../../state";
import { parseGenericState } from "../../state";
import type { Account, User } from "../../types";
import { isAPIError } from "../../utils/is-api-error";
import { getOrigin } from "../../utils/url";
import { PACKAGE_VERSION } from "../../version";
import {
@@ -242,12 +243,20 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
ctx.context.logger.warn("Failed to clean up OAuth state", e);
}
const result = await handleOAuthUserInfo(ctx, {
userInfo: payload.userInfo,
account: payload.account,
callbackURL: payload.callbackURL,
disableSignUp: payload.disableSignUp,
});
let result: Awaited<ReturnType<typeof handleOAuthUserInfo>>;
try {
result = await handleOAuthUserInfo(ctx, {
userInfo: payload.userInfo,
account: payload.account,
callbackURL: payload.callbackURL,
disableSignUp: payload.disableSignUp,
});
} catch (e) {
if (isAPIError(e) && e.body?.code) {
throw redirectOnError(ctx, errorURL, e.body.code, e.body.message);
}
throw e;
}
if (result.error || !result.data) {
ctx.context.logger.error(
"Failed to create user or session",
@@ -79,7 +79,10 @@ export function redirectOnError(
ctx: GenericEndpointContext,
errorURL: string,
error: string,
description?: string,
): never {
const params = new URLSearchParams({ error });
if (description) params.set("error_description", description);
const sep = errorURL.includes("?") ? "&" : "?";
throw ctx.redirect(`${errorURL}${sep}error=${error}`);
throw ctx.redirect(`${errorURL}${sep}${params.toString()}`);
}
+123
View File
@@ -1,3 +1,4 @@
import { APIError } from "@better-auth/core/error";
import { betterFetch } from "@better-fetch/fetch";
import { createAuthClient } from "better-auth/client";
import { organization } from "better-auth/plugins";
@@ -1837,3 +1838,125 @@ describe("SSO OIDC UserInfo endpoint sub claim mapping", async () => {
});
});
});
describe("SSO OIDC hook rejection redirect", async () => {
const hookServer = new OAuth2Server();
beforeAll(async () => {
await hookServer.issuer.keys.generate("RS256");
await hookServer.start(8090, "localhost");
});
afterAll(async () => {
await hookServer.stop().catch(() => {});
});
hookServer.service.on("beforeUserinfo", (userInfoResponse) => {
userInfoResponse.body = {
email: "rejected@test.com",
name: "Rejected User",
sub: "rejected-sub",
email_verified: true,
};
userInfoResponse.statusCode = 200;
});
hookServer.service.on("beforeTokenSigning", (token) => {
token.payload.email = "rejected@test.com";
token.payload.email_verified = true;
});
const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
await getTestInstance({
trustedOrigins: ["http://localhost:8090", "https://frontend.example.com"],
plugins: [sso()],
databaseHooks: {
session: {
create: {
before: async (session, ctx) => {
if (!ctx) return;
const user = await ctx.context.internalAdapter.findUserById(
session.userId,
);
if (user?.email === "rejected@test.com") {
throw APIError.from("FORBIDDEN", {
code: "HOOK_REJECTED",
message: "SSO hook rejected this user",
});
}
},
},
},
},
});
const authClient = createAuthClient({
plugins: [ssoClient()],
baseURL: "http://localhost:3000",
fetchOptions: { customFetchImpl },
});
it("should redirect to cross-origin errorCallbackURL when a session hook throws APIError", async () => {
const { headers: adminHeaders } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
issuer: hookServer.issuer.url!,
domain: "hook-reject.com",
providerId: "hook-reject",
oidcConfig: {
clientId: "test",
clientSecret: "test",
authorizationEndpoint: `${hookServer.issuer.url}/authorize`,
tokenEndpoint: `${hookServer.issuer.url}/token`,
jwksEndpoint: `${hookServer.issuer.url}/jwks`,
discoveryEndpoint: `${hookServer.issuer.url}/.well-known/openid-configuration`,
mapping: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
},
},
},
headers: adminHeaders,
});
const signInHeaders = new Headers();
const res = await authClient.signIn.sso({
providerId: "hook-reject",
callbackURL: "https://frontend.example.com/dashboard",
errorCallbackURL: "https://frontend.example.com/auth-error",
fetchOptions: {
throw: true,
onSuccess: cookieSetter(signInHeaders),
},
});
let location: string | null = null;
await betterFetch(res.url, {
method: "GET",
redirect: "manual",
onError(context) {
location = context.response.headers.get("location");
},
});
let callbackURL = "";
await betterFetch(location!, {
method: "GET",
customFetchImpl,
headers: signInHeaders,
onError(context) {
callbackURL = context.response.headers.get("location") || "";
},
});
const url = new URL(callbackURL);
expect(url.origin).toBe("https://frontend.example.com");
expect(url.pathname).toBe("/auth-error");
expect(url.searchParams.get("error")).toBe("HOOK_REJECTED");
expect(url.searchParams.get("error_description")).toBe(
"SSO hook rejected this user",
);
});
});
+30 -17
View File
@@ -1,3 +1,4 @@
import { isAPIError } from "@better-auth/core/utils/is-api-error";
import type { User } from "better-auth";
import { APIError } from "better-auth/api";
import { setSessionCookie } from "better-auth/cookies";
@@ -427,24 +428,36 @@ export async function processSAMLResponse(
relayState?.callbackURL ||
parsedSamlConfig.callbackUrl ||
ctx.context.baseURL;
const errorUrl = relayState?.errorURL || samlRedirectUrl;
const result = await handleOAuthUserInfo(ctx, {
userInfo: {
email: userInfo.email as string,
name: (userInfo.name || userInfo.email) as string,
id: userInfo.id as string,
emailVerified: Boolean(userInfo.emailVerified),
},
account: {
providerId,
accountId: userInfo.id as string,
accessToken: "",
refreshToken: "",
},
callbackURL: callbackUrl,
disableSignUp: options?.disableImplicitSignUp,
isTrustedProvider,
});
let result: Awaited<ReturnType<typeof handleOAuthUserInfo>>;
try {
result = await handleOAuthUserInfo(ctx, {
userInfo: {
email: userInfo.email as string,
name: (userInfo.name || userInfo.email) as string,
id: userInfo.id as string,
emailVerified: Boolean(userInfo.emailVerified),
},
account: {
providerId,
accountId: userInfo.id as string,
accessToken: "",
refreshToken: "",
},
callbackURL: callbackUrl,
disableSignUp: options?.disableImplicitSignUp,
isTrustedProvider,
});
} catch (e) {
if (isAPIError(e) && e.body?.code) {
const params = new URLSearchParams({ error: e.body.code });
if (e.body.message) params.set("error_description", e.body.message);
const sep = errorUrl.includes("?") ? "&" : "?";
throw ctx.redirect(`${errorUrl}${sep}${params.toString()}`);
}
throw e;
}
if (result.error) {
throw ctx.redirect(
+38 -25
View File
@@ -1,3 +1,4 @@
import { isAPIError } from "@better-auth/core/utils/is-api-error";
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
import {
createAuthorizationURL,
@@ -1654,31 +1655,43 @@ async function handleOIDCCallback(
(provider as { domainVerified?: boolean }).domainVerified === true &&
validateEmailDomain(userInfo.email, provider.domain);
const linked = await handleOAuthUserInfo(ctx, {
userInfo: {
email: userInfo.email,
name: userInfo.name || "",
id: userInfo.id,
image: userInfo.image,
emailVerified: options?.trustEmailVerified
? userInfo.emailVerified || false
: false,
},
account: {
idToken: tokenResponse.idToken,
accessToken: tokenResponse.accessToken,
refreshToken: tokenResponse.refreshToken,
accountId: userInfo.id,
providerId: provider.providerId,
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
scope: tokenResponse.scopes?.join(","),
},
callbackURL,
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
overrideUserInfo: config.overrideUserInfo,
isTrustedProvider,
});
let linked: Awaited<ReturnType<typeof handleOAuthUserInfo>>;
try {
linked = await handleOAuthUserInfo(ctx, {
userInfo: {
email: userInfo.email,
name: userInfo.name || "",
id: userInfo.id,
image: userInfo.image,
emailVerified: options?.trustEmailVerified
? userInfo.emailVerified || false
: false,
},
account: {
idToken: tokenResponse.idToken,
accessToken: tokenResponse.accessToken,
refreshToken: tokenResponse.refreshToken,
accountId: userInfo.id,
providerId: provider.providerId,
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
scope: tokenResponse.scopes?.join(","),
},
callbackURL,
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
overrideUserInfo: config.overrideUserInfo,
isTrustedProvider,
});
} catch (e) {
if (isAPIError(e) && e.body?.code) {
const baseURL = errorURL || callbackURL;
const params = new URLSearchParams({ error: e.body.code });
if (e.body.message) params.set("error_description", e.body.message);
const sep = baseURL.includes("?") ? "&" : "?";
throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
}
throw e;
}
if (linked.error) {
throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
}