fix(oauth): forward hook errors and sync linked profiles on rewritten callback (#9702, #9788, #8758)

Re-applies three main fixes onto next's rewritten OAuth callback, which
now routes generic-oauth through the shared callback route.

- #9702/#9788: a before-callback hook that rejects sign-in (the banned-user
  guard is the flagship case) now redirects to the per-flow errorURL
  carrying the hook's machine-readable code and message, instead of
  surfacing a raw error response. handleOAuthUserInfo is wrapped in
  try/catch; the hook's APIError code is forwarded verbatim (it is
  app-defined, not a member of OAUTH_CALLBACK_ERROR_CODES) via next's
  local redirectOnError closure. The admin plugin's stale inline /callback
  redirect is removed so the banned-user path flows through that single
  forwarder and emits BANNED_USER with a URL-encoded description.
- #8758: accountLinking.updateUserInfoOnLink now applies on the explicit
  OAuth link path, not only id-token linking.

The regression tests (banned social sign-in emits error=BANNED_USER, the
cross-origin errorCallbackURL case, and the 403-JSON id-token case) arrived
via the merge and were red on the baseline; these source changes make them
pass.

(cherry picked from commit 1d893ae572277447025e29462946a7aeb11443ac)
This commit is contained in:
Gustavo Valverde
2026-05-31 00:10:35 +01:00
parent ef4fb0ff95
commit d309e5d2ba
2 changed files with 33 additions and 24 deletions
+33 -15
View File
@@ -6,10 +6,14 @@ import { getAwaitableValue } from "../../context/helpers";
import { setSessionCookie } from "../../cookies";
import { OAUTH_CALLBACK_ERROR_CODES } from "../../oauth2/error-codes";
import { missingEmailLogMessage } from "../../oauth2/errors";
import { handleOAuthUserInfo } from "../../oauth2/link-account";
import {
applyUpdateUserInfoOnLink,
handleOAuthUserInfo,
} from "../../oauth2/link-account";
import { generateState, 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(),
@@ -274,6 +278,7 @@ export const callbackOAuth = createAuthEndpoint(
);
}
}
await applyUpdateUserInfoOnLink(c, link.userId, userInfo);
let toRedirectTo: string;
try {
const url = callbackURL;
@@ -294,20 +299,33 @@ 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) {
// A before-callback hook (for example the admin plugin's banned-user
// guard) can reject sign-in by throwing an APIError. Forward its
// machine-readable code and message to the per-flow errorURL instead
// of surfacing a raw error response. The code is app-defined, so it is
// forwarded verbatim rather than mapped onto OAUTH_CALLBACK_ERROR_CODES.
if (isAPIError(e) && e.body?.code) {
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("_"));
@@ -113,15 +113,6 @@ export const admin = <O extends AdminOptions>(options?: O | undefined) => {
return;
}
if (ctx.path.startsWith("/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",