fix(oauth-proxy): support social account linking (#11268)

This commit is contained in:
Taesu
2026-09-23 22:01:42 +00:00
committed by GitHub
parent 3d0efa308d
commit 2fa501cdf9
5 changed files with 393 additions and 120 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"better-auth": patch
---
Support linking social accounts through the OAuth Proxy plugin.
+17 -101
View File
@@ -1,7 +1,6 @@
import { createAuthEndpoint } from "@better-auth/core/api";
import type { AccountKey } from "@better-auth/core/db";
import type { OAuth2Tokens } from "@better-auth/core/oauth2";
import { mergeScopes } from "@better-auth/core/oauth2";
import { safeJSONParse } from "@better-auth/core/utils/json";
import { appendQueryParams } from "@better-auth/core/utils/url";
import * as z from "zod";
@@ -16,18 +15,17 @@ import {
OAUTH_CALLBACK_ERROR_CODES,
} from "../../oauth2/errors";
import {
applyUpdateUserInfoOnLink,
handleOAuthUserInfo,
linkOAuthAccount,
} from "../../oauth2/link-account";
import {
generateIdTokenNonce,
generateState,
parseState,
} from "../../oauth2/state";
import { getOAuthCallbackPath, setTokenUtil } from "../../oauth2/utils";
import { getOAuthCallbackPath } from "../../oauth2/utils";
import { HIDE_METADATA } from "../../utils/hide-metadata";
import { isAPIError } from "../../utils/is-api-error";
import { assertValidUserInfo } from "../../utils/validate-user-info";
const schema = z.object({
code: z.string().optional(),
@@ -261,112 +259,30 @@ export const callbackOAuth = createAuthEndpoint(
c.context.logger.error("No callback URL found");
throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.NO_CALLBACK_URL);
}
const accountData = {
...accountKey,
...tokens,
scope: tokens.scopes?.join(","),
};
if (link) {
// Link-account creates no user row, so the gate runs here rather than
// inside createUser.
try {
await assertValidUserInfo(c, {
user: {
...userInfo,
id: link.userId,
email: userInfo.email ?? undefined,
},
source: {
action: "link-account",
method: "oauth",
oauth: {
providerId: provider.id,
profile: providerProfile,
},
},
});
} catch (e) {
if (isAPIError(e) && e.body?.code) {
throw redirectOnError(e.body.code, e.body.message);
}
throw e;
const linkResult = await linkOAuthAccount(c, {
link,
userInfo,
account: accountData,
profile: providerProfile,
scopes: tokens.scopes,
});
if (!linkResult.linked) {
return redirectOnError(linkResult.error.code, linkResult.error.message);
}
const isTrustedProvider = c.context.trustedProviders.includes(
provider.id,
);
if (
(!isTrustedProvider && !userInfo.emailVerified) ||
c.context.options.account?.accountLinking?.enabled === false
) {
c.context.logger.error("Unable to link account - untrusted provider");
return redirectOnError(
OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT,
);
}
if (
userInfo.email?.toLowerCase() !== link.email.toLowerCase() &&
c.context.options.account?.accountLinking?.allowDifferentEmails !== true
) {
return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.EMAIL_DOES_NOT_MATCH);
}
const existingAccount =
await c.context.internalAdapter.findAccountByKey(accountKey);
if (existingAccount) {
if (existingAccount.userId.toString() !== link.userId.toString()) {
return redirectOnError(
OAUTH_CALLBACK_ERROR_CODES.ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER,
);
}
const mergedScope = mergeScopes(existingAccount.scope, tokens.scopes);
const updateData = Object.fromEntries(
Object.entries({
providerId: provider.id,
accessToken: await setTokenUtil(tokens.accessToken, c.context),
refreshToken: await setTokenUtil(tokens.refreshToken, c.context),
idToken: tokens.idToken,
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
scope: mergedScope || undefined,
}).filter(([_, value]) => value !== undefined),
);
await c.context.internalAdapter.updateAccount(
existingAccount.id,
updateData,
);
} else {
const newAccount = await c.context.internalAdapter.createAccount({
userId: link.userId,
...accountKey,
...tokens,
accessToken: await setTokenUtil(tokens.accessToken, c.context),
refreshToken: await setTokenUtil(tokens.refreshToken, c.context),
scope: tokens.scopes?.join(","),
});
if (!newAccount) {
return redirectOnError(
OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT,
);
}
}
await applyUpdateUserInfoOnLink(c, link.userId, userInfo);
let toRedirectTo: string;
try {
const url = callbackURL;
toRedirectTo = url.toString();
} catch {
toRedirectTo = callbackURL;
}
throw c.redirect(toRedirectTo);
throw c.redirect(callbackURL);
}
if (!userInfo.email) {
c.context.logger.error(missingEmailLogMessage(provider.id));
return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.EMAIL_NOT_FOUND);
}
const accountData = {
...accountKey,
...tokens,
scope: tokens.scopes?.join(","),
};
let result: Awaited<ReturnType<typeof handleOAuthUserInfo>>;
try {
result = await handleOAuthUserInfo(c, {
+139 -14
View File
@@ -8,6 +8,7 @@ import {
} from "@better-auth/core/context";
import { isDevelopment } from "@better-auth/core/env";
import { APIError } from "@better-auth/core/error";
import { mergeScopes } from "@better-auth/core/oauth2";
import { createEmailVerificationToken } from "../api";
import { setAccountCookie } from "../cookies/session-store";
import { parseAdditionalUserInputFromProviderProfile } from "../db";
@@ -17,6 +18,143 @@ import { assertValidUserInfo } from "../utils/validate-user-info";
import { OAUTH_CALLBACK_ERROR_CODES, redirectOnError } from "./errors";
import { setTokenUtil } from "./utils";
type OAuthAccountData = Omit<
Account,
"id" | "userId" | "createdAt" | "updatedAt"
>;
/**
* Provider profile a freshly linked account may copy onto the local user.
* `email` and `emailVerified` are identity anchors and are stripped before
* the remaining fields are written. Provider identity is resolved separately
* from the raw profile through the provider's account-key contract.
*/
type LinkedProviderProfile = {
name?: string | undefined;
email?: string | null | undefined;
emailVerified?: boolean | undefined;
image?: string | null | undefined;
};
interface LinkOAuthAccountOptions {
link: {
userId: string;
email: string;
};
userInfo: LinkedProviderProfile;
account: OAuthAccountData;
profile: Record<string, unknown>;
scopes?: string[] | undefined;
}
interface LinkOAuthAccountFailure {
linked: false;
error: {
code: string;
message?: string | undefined;
};
}
type LinkOAuthAccountResult = { linked: true } | LinkOAuthAccountFailure;
function linkOAuthAccountFailure(
code: string,
message?: string,
): LinkOAuthAccountFailure {
return { linked: false, error: { code, message } };
}
export async function linkOAuthAccount(
c: GenericEndpointContext,
{ link, userInfo, account, profile, scopes }: LinkOAuthAccountOptions,
): Promise<LinkOAuthAccountResult> {
try {
await assertValidUserInfo(c, {
user: {
...userInfo,
id: link.userId,
email: userInfo.email ?? undefined,
},
source: {
action: "link-account",
method: "oauth",
oauth: {
providerId: account.providerId,
profile,
},
},
});
} catch (error) {
if (!isAPIError(error) || !error.body?.code) throw error;
return linkOAuthAccountFailure(error.body.code, error.body.message);
}
const isTrustedProvider = c.context.trustedProviders.includes(
account.providerId,
);
if (
(!isTrustedProvider && !userInfo.emailVerified) ||
c.context.options.account?.accountLinking?.enabled === false
) {
c.context.logger.error("Unable to link account - untrusted provider");
return linkOAuthAccountFailure(
OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT,
);
}
if (
userInfo.email?.toLowerCase() !== link.email.toLowerCase() &&
c.context.options.account?.accountLinking?.allowDifferentEmails !== true
) {
return linkOAuthAccountFailure(
OAUTH_CALLBACK_ERROR_CODES.EMAIL_DOES_NOT_MATCH,
);
}
const existingAccount =
await c.context.internalAdapter.findAccountByKey(account);
if (existingAccount) {
if (existingAccount.userId.toString() !== link.userId.toString()) {
return linkOAuthAccountFailure(
OAUTH_CALLBACK_ERROR_CODES.ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER,
);
}
const mergedScope = mergeScopes(existingAccount.scope, scopes);
const updateData = Object.fromEntries(
Object.entries({
providerId: account.providerId,
accessToken: await setTokenUtil(account.accessToken, c.context),
refreshToken: await setTokenUtil(account.refreshToken, c.context),
idToken: account.idToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
scope: mergedScope || undefined,
}).filter(([_, value]) => value !== undefined),
);
await c.context.internalAdapter.updateAccount(
existingAccount.id,
updateData,
);
} else {
const newAccount = await c.context.internalAdapter.createAccount({
userId: link.userId,
...account,
accessToken: await setTokenUtil(account.accessToken, c.context),
refreshToken: await setTokenUtil(account.refreshToken, c.context),
scope: scopes?.join(",") ?? account.scope,
});
if (!newAccount) {
return linkOAuthAccountFailure(
OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT,
);
}
}
await applyUpdateUserInfoOnLink(c, link.userId, userInfo);
return { linked: true };
}
// TODO(#9124): v2 widens `User.email` to nullable; every `userInfo.email.toLowerCase()`
// call below needs null-safety before account recognition can be fully
// independent from email-based implicit linking.
@@ -24,7 +162,7 @@ export async function handleOAuthUserInfo(
c: GenericEndpointContext,
opts: {
userInfo: Omit<User, "createdAt" | "updatedAt">;
account: Omit<Account, "id" | "userId" | "createdAt" | "updatedAt">;
account: OAuthAccountData;
callbackURL?: string | undefined;
disableSignUp?: boolean | undefined;
overrideUserInfo?: boolean | undefined;
@@ -569,19 +707,6 @@ async function dispatchVerificationEmail(
}
}
/**
* Provider profile a freshly linked account may copy onto the local user.
* `email` and `emailVerified` are identity anchors and are stripped before
* the remaining fields are written. Provider identity is resolved separately
* from the raw profile through the provider's account-key contract.
*/
type LinkedProviderProfile = {
name?: string | undefined;
email?: string | null | undefined;
emailVerified?: boolean | undefined;
image?: string | null | undefined;
};
/**
* Apply the `account.accountLinking.updateUserInfoOnLink` policy: when enabled,
* copy the freshly linked provider's profile onto the local user, matching the
@@ -24,7 +24,10 @@ import {
toOAuthProfileRecord,
} from "../../oauth2/account-key";
import { redirectOnError } from "../../oauth2/errors";
import { handleOAuthUserInfo } from "../../oauth2/link-account";
import {
handleOAuthUserInfo,
linkOAuthAccount,
} from "../../oauth2/link-account";
import { getOAuthCallbackPath } from "../../oauth2/utils";
import type { StateData } from "../../state";
import { parseGenericState } from "../../state";
@@ -118,6 +121,7 @@ const passthroughPayloadSchema = z.looseObject({
}).shape,
),
profile: z.record(z.string(), z.unknown()).optional(),
scopes: z.array(z.string()).optional(),
state: z.string().min(1),
callbackURL: z.string().min(1),
newUserURL: z.string().optional(),
@@ -234,7 +238,24 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
if (!stateData) {
throw redirectOnError(ctx, errorURL, "state_mismatch");
}
if (stateData.link) {
const linkResult = await linkOAuthAccount(ctx, {
link: stateData.link,
userInfo: payload.userInfo,
account: payload.account,
profile: payload.profile ?? {},
scopes: payload.scopes ?? payload.account.scope?.split(","),
});
if (!linkResult.linked) {
throw redirectOnError(
ctx,
errorURL,
linkResult.error.code,
linkResult.error.message,
);
}
throw ctx.redirect(payload.callbackURL);
}
let result: Awaited<ReturnType<typeof handleOAuthUserInfo>>;
try {
result = await handleOAuthUserInfo(ctx, {
@@ -339,7 +360,10 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
before: [
{
matcher(context) {
return !!context.path?.startsWith("/sign-in/social");
return (
!!context.path?.startsWith("/sign-in/social") ||
context.path === "/link-social"
);
},
handler: createAuthMiddleware(async (ctx) => {
const skipProxy = checkSkipProxy(ctx, opts);
@@ -562,6 +586,7 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
emailVerified: userInfo.emailVerified,
},
profile: providerProfile,
scopes: tokens.scopes,
account: {
...accountKey,
accessToken: tokens.accessToken,
@@ -597,7 +622,10 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
after: [
{
matcher(context) {
return !!context.path?.startsWith("/sign-in/social");
return (
!!context.path?.startsWith("/sign-in/social") ||
context.path === "/link-social"
);
},
handler: createAuthMiddleware(async (ctx) => {
const skipProxy = checkSkipProxy(ctx, opts);
@@ -616,7 +644,7 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
}
const { url: providerURL } = signInResponse;
if (typeof providerURL !== "string") {
if (typeof providerURL !== "string" || providerURL.length === 0) {
return;
}
@@ -126,6 +126,205 @@ describe("oauth-proxy", async () => {
);
});
/**
* @see https://github.com/better-auth/better-auth/issues/9390
*/
describe("account linking", () => {
const proxySecret = "shared-oauth-proxy-secret";
const socialProviders = {
google: {
clientId: "test",
clientSecret: "test",
verifyIdToken: async () => true,
},
};
async function createAccountLinkingFlow() {
const preview = await getTestInstance({
baseURL: "http://preview.example.com",
secret: "preview-main-secret",
plugins: [
oAuthProxy({
productionURL: "http://localhost:3000",
secret: proxySecret,
}),
],
socialProviders,
account: {
accountLinking: { allowDifferentEmails: true },
},
});
const production = await getTestInstance(
{
baseURL: "http://localhost:3000",
secret: "production-main-secret",
plugins: [oAuthProxy({ secret: proxySecret })],
socialProviders,
},
{ disableTestUser: true },
);
const { user, headers } = await preview.signInWithTestUser();
async function completeLink(accessToken: string, scope: string) {
let tokenRedirectURI: string | null = null;
server.use(
http.post(
"https://oauth2.googleapis.com/token",
async ({ request }) => {
const body = await request.formData();
const redirectURI = body.get("redirect_uri");
tokenRedirectURI =
typeof redirectURI === "string" ? redirectURI : null;
return HttpResponse.json({
access_token: accessToken,
refresh_token: "test",
id_token: testIdToken,
expires_in: 3600,
refresh_token_expires_in: 86400,
scope,
});
},
),
);
const link = await preview.client.linkSocial(
{
provider: "google",
callbackURL: "/settings",
},
{ headers },
);
const authorizationURL = link.data?.url;
if (!authorizationURL) throw new Error("Authorization URL is missing");
const authorization = new URL(authorizationURL);
const authorizationRedirectURI =
authorization.searchParams.get("redirect_uri");
const state = authorization.searchParams.get("state");
if (!state) throw new Error("OAuth state is missing");
let proxyCallbackURL: string | null = null;
await production.client.$fetch(
`/callback/google?code=test&state=${encodeURIComponent(state)}`,
{
onError(context) {
proxyCallbackURL = context.response.headers.get("location");
},
},
);
if (!proxyCallbackURL) throw new Error("Proxy callback URL is missing");
const proxyCallback = new URL(proxyCallbackURL);
const callbackPath = `${proxyCallback.pathname.replace(
"/api/auth",
"",
)}${proxyCallback.search}`;
let finalRedirect: string | null = null;
await preview.client.$fetch(callbackPath, {
headers,
onError(context) {
finalRedirect = context.response.headers.get("location");
},
});
return {
authorizationRedirectURI,
finalRedirect,
tokenRedirectURI,
};
}
return { completeLink, preview, user };
}
it("creates and updates the provider account for the initiating user", async () => {
const { completeLink, preview, user } = await createAccountLinkingFlow();
const previewContext = await preview.auth.$context;
const sessionsBefore = await previewContext.adapter.findMany<{
id: string;
}>({
model: "session",
where: [{ field: "userId", value: user.id }],
});
const firstLink = await completeLink(
"initial-access-token",
"openid profile",
);
const accountsAfterFirstLink =
await previewContext.internalAdapter.findAccounts(user.id);
const firstGoogleAccount = accountsAfterFirstLink.find(
(account) => account.providerId === "google",
);
expect(firstLink.authorizationRedirectURI).toBe(
"http://localhost:3000/api/auth/callback/google",
);
expect(firstLink.tokenRedirectURI).toBe(
firstLink.authorizationRedirectURI,
);
expect(firstLink.finalRedirect).toBe("/settings");
expect(firstGoogleAccount?.scope).toBe("openid,profile");
const secondLink = await completeLink(
"updated-access-token",
"https://www.googleapis.com/auth/drive.readonly",
);
const accountsAfterSecondLink =
await previewContext.internalAdapter.findAccounts(user.id);
const googleAccounts = accountsAfterSecondLink.filter(
(account) => account.providerId === "google",
);
const sessionsAfter = await previewContext.adapter.findMany<{
id: string;
}>({
model: "session",
where: [{ field: "userId", value: user.id }],
});
expect(secondLink.finalRedirect).toBe("/settings");
expect(googleAccounts).toHaveLength(1);
expect(googleAccounts[0]?.accessToken).toBe("updated-access-token");
expect(googleAccounts[0]?.scope?.split(",").sort()).toEqual([
"https://www.googleapis.com/auth/drive.readonly",
"openid",
"profile",
]);
expect(sessionsAfter.map((session) => session.id)).toEqual(
sessionsBefore.map((session) => session.id),
);
});
it("links an account directly with an ID token", async () => {
const { auth, client, signInWithTestUser } = await getTestInstance({
baseURL: "http://preview.example.com",
plugins: [oAuthProxy({ productionURL: "http://localhost:3000" })],
socialProviders,
account: {
accountLinking: { allowDifferentEmails: true },
},
});
const { headers, user } = await signInWithTestUser();
const result = await client.linkSocial(
{
provider: "google",
idToken: { token: testIdToken },
},
{ headers },
);
expect(result.error).toBeNull();
expect(result.data).toMatchObject({
status: true,
redirect: false,
});
const context = await auth.$context;
const accounts = await context.internalAdapter.findAccounts(user.id);
expect(accounts.some((account) => account.providerId === "google")).toBe(
true,
);
});
});
it("should redirect to proxy url with profile data (passthrough)", async () => {
const { client } = await getTestInstance({
plugins: [