diff --git a/.changeset/calm-apples-link.md b/.changeset/calm-apples-link.md new file mode 100644 index 0000000000..0cab1d034c --- /dev/null +++ b/.changeset/calm-apples-link.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Support linking social accounts through the OAuth Proxy plugin. diff --git a/packages/better-auth/src/api/routes/callback.ts b/packages/better-auth/src/api/routes/callback.ts index d313866939..fd41096ec4 100644 --- a/packages/better-auth/src/api/routes/callback.ts +++ b/packages/better-auth/src/api/routes/callback.ts @@ -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>; try { result = await handleOAuthUserInfo(c, { diff --git a/packages/better-auth/src/oauth2/link-account.ts b/packages/better-auth/src/oauth2/link-account.ts index 9d48a96396..b83d703da4 100644 --- a/packages/better-auth/src/oauth2/link-account.ts +++ b/packages/better-auth/src/oauth2/link-account.ts @@ -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; + 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 { + 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; - account: Omit; + 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 diff --git a/packages/better-auth/src/plugins/oauth-proxy/index.ts b/packages/better-auth/src/plugins/oauth-proxy/index.ts index 3ad52f6a4f..51960a46f3 100644 --- a/packages/better-auth/src/plugins/oauth-proxy/index.ts +++ b/packages/better-auth/src/plugins/oauth-proxy/index.ts @@ -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 = (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>; try { result = await handleOAuthUserInfo(ctx, { @@ -339,7 +360,10 @@ export const oAuthProxy = (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 = (opts?: O) => { emailVerified: userInfo.emailVerified, }, profile: providerProfile, + scopes: tokens.scopes, account: { ...accountKey, accessToken: tokens.accessToken, @@ -597,7 +622,10 @@ export const oAuthProxy = (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 = (opts?: O) => { } const { url: providerURL } = signInResponse; - if (typeof providerURL !== "string") { + if (typeof providerURL !== "string" || providerURL.length === 0) { return; } diff --git a/packages/better-auth/src/plugins/oauth-proxy/oauth-proxy.test.ts b/packages/better-auth/src/plugins/oauth-proxy/oauth-proxy.test.ts index 279429b77b..ad0aa59e40 100644 --- a/packages/better-auth/src/plugins/oauth-proxy/oauth-proxy.test.ts +++ b/packages/better-auth/src/plugins/oauth-proxy/oauth-proxy.test.ts @@ -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: [