From c14f127fd32506c36d5d48d878b35a8c68a7e521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20LEDUCQ?= <73238856+leoleducq@users.noreply.github.com> Date: Fri, 11 Apr 2025 12:54:30 +0200 Subject: [PATCH] feat(socialLink): add support for custom scopes in social account linking (#2074) * feat(socialLink): add support for custom scopes in social account linking - Updated documentation to include information on requesting specific scopes when linking social accounts. - Added a test case to verify that custom scopes are correctly passed to the authorization URL. - Modified the account linking endpoint to accept additional scopes as an optional parameter. * chore: lint * fix(account): update account linking logic and tests --------- Co-authored-by: Bereket Engida --- docs/content/docs/concepts/users-accounts.mdx | 10 ++++ .../src/api/routes/account.test.ts | 30 ++++++++++-- .../better-auth/src/api/routes/account.ts | 11 +++++ .../better-auth/src/api/routes/callback.ts | 44 ++++++++++------- .../better-auth/src/db/internal-adapter.ts | 4 +- .../src/plugins/generic-oauth/index.ts | 48 ++++++++++++++----- 6 files changed, 114 insertions(+), 33 deletions(-) diff --git a/docs/content/docs/concepts/users-accounts.mdx b/docs/content/docs/concepts/users-accounts.mdx index 682da3f0..d3cded20 100644 --- a/docs/content/docs/concepts/users-accounts.mdx +++ b/docs/content/docs/concepts/users-accounts.mdx @@ -304,6 +304,16 @@ Users already signed in can manually link their account to additional social pro }); ``` + You can also request specific scopes when linking a social account, which can be different from the scopes used during the initial authentication: + + ```ts + await authClient.linkSocial({ + provider: "google", + callbackURL: "/callback", + scopes: ["https://www.googleapis.com/auth/drive.readonly"] // Request additional scopes + }); + ``` + If you want your users to be able to link a social account with a different email address than the user, or if you want to use a provider that does not return email addresses, you will need to enable this in the account linking settings. ```ts title="auth.ts" export const auth = betterAuth({ diff --git a/packages/better-auth/src/api/routes/account.test.ts b/packages/better-auth/src/api/routes/account.test.ts index 5b7d0f69..f8ee62c9 100644 --- a/packages/better-auth/src/api/routes/account.test.ts +++ b/packages/better-auth/src/api/routes/account.test.ts @@ -120,6 +120,30 @@ describe("account", async () => { expect(accounts.data?.length).toBe(2); }); + it("should pass custom scopes to authorization URL", async () => { + const { headers: headers2 } = await signInWithTestUser(); + const customScope = "https://www.googleapis.com/auth/drive.readonly"; + const linkAccountRes = await client.linkSocial( + { + provider: "google", + callbackURL: "/callback", + scopes: [customScope], + }, + { + headers: headers2, + }, + ); + + expect(linkAccountRes.data).toMatchObject({ + url: expect.stringContaining("google.com"), + redirect: true, + }); + + const url = new URL(linkAccountRes.data!.url); + const scopesParam = url.searchParams.get("scope"); + expect(scopesParam).toContain(customScope); + }); + it("should link second account from the same provider", async () => { const { headers: headers2 } = await signInWithTestUser(); const linkAccountRes = await client.linkSocial( @@ -166,7 +190,7 @@ describe("account", async () => { const accounts = await client.listAccounts({ fetchOptions: { headers: headers3 }, }); - expect(accounts.data?.length).toBe(3); + expect(accounts.data?.length).toBe(2); }); it("should unlink account", async () => { const { headers } = await signInWithTestUser(); @@ -175,7 +199,7 @@ describe("account", async () => { headers, }, }); - expect(previousAccounts.data?.length).toBe(3); + expect(previousAccounts.data?.length).toBe(2); const unlinkAccountId = previousAccounts.data![1].accountId; const unlinkRes = await client.unlinkAccount({ providerId: "google", @@ -190,7 +214,7 @@ describe("account", async () => { headers, }, }); - expect(accounts.data?.length).toBe(2); + expect(accounts.data?.length).toBe(1); }); it("should fail to unlink the last account of a provider", async () => { diff --git a/packages/better-auth/src/api/routes/account.ts b/packages/better-auth/src/api/routes/account.ts index cb5b6344..8b30d69c 100644 --- a/packages/better-auth/src/api/routes/account.ts +++ b/packages/better-auth/src/api/routes/account.ts @@ -102,6 +102,16 @@ export const linkSocialAccount = createAuthEndpoint( provider: z.enum(socialProviderList, { description: "The OAuth2 provider to use", }), + /** + * Additional scopes to request when linking the account. + * This is useful for requesting additional permissions when + * linking a social account compared to the initial authentication. + */ + scopes: z + .array(z.string(), { + description: "Additional scopes to request from the provider", + }) + .optional(), }), use: [sessionMiddleware], metadata: { @@ -163,6 +173,7 @@ export const linkSocialAccount = createAuthEndpoint( state: state.state, codeVerifier: state.codeVerifier, redirectURI: `${c.context.baseURL}/callback/${provider.id}`, + scopes: c.body.scopes, }); return c.json({ diff --git a/packages/better-auth/src/api/routes/callback.ts b/packages/better-auth/src/api/routes/callback.ts index 0c3f954e..905bf159 100644 --- a/packages/better-auth/src/api/routes/callback.ts +++ b/packages/better-auth/src/api/routes/callback.ts @@ -134,23 +134,35 @@ export const callbackOAuth = createAuthEndpoint( if (existingAccount.userId.toString() !== link.userId.toString()) { return redirectOnError("account_already_linked_to_different_user"); } + const updateData = Object.fromEntries( + Object.entries({ + accessToken: tokens.accessToken, + idToken: tokens.idToken, + refreshToken: tokens.refreshToken, + accessTokenExpiresAt: tokens.accessTokenExpiresAt, + refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, + scope: tokens.scopes?.join(","), + }).filter(([_, value]) => value !== undefined), + ); + await c.context.internalAdapter.updateAccount( + existingAccount.id, + updateData, + ); + } else { + const newAccount = await c.context.internalAdapter.createAccount( + { + userId: link.userId, + providerId: provider.id, + accountId: userInfo.id, + ...tokens, + scope: tokens.scopes?.join(","), + }, + c, + ); + if (!newAccount) { + return redirectOnError("unable_to_link_account"); + } } - - const newAccount = await c.context.internalAdapter.createAccount( - { - userId: link.userId, - providerId: provider.id, - accountId: userInfo.id, - ...tokens, - scope: tokens.scopes?.join(","), - }, - c, - ); - - if (!newAccount) { - return redirectOnError("unable_to_link_account"); - } - let toRedirectTo: string; try { const url = callbackURL; diff --git a/packages/better-auth/src/db/internal-adapter.ts b/packages/better-auth/src/db/internal-adapter.ts index 45c27427..2ca6ee06 100644 --- a/packages/better-auth/src/db/internal-adapter.ts +++ b/packages/better-auth/src/db/internal-adapter.ts @@ -750,13 +750,13 @@ export const createInternalAdapter = ( return account; }, updateAccount: async ( - accountId: string, + id: string, data: Partial, context?: GenericEndpointContext, ) => { const account = await updateWithHooks( data, - [{ field: "id", value: accountId }], + [{ field: "id", value: id }], "account", undefined, context, diff --git a/packages/better-auth/src/plugins/generic-oauth/index.ts b/packages/better-auth/src/plugins/generic-oauth/index.ts index 7f074283..c107690c 100644 --- a/packages/better-auth/src/plugins/generic-oauth/index.ts +++ b/packages/better-auth/src/plugins/generic-oauth/index.ts @@ -620,18 +620,42 @@ export const genericOAuth = (options: GenericOAuthOptions) => { ) { return redirectOnError("email_doesn't_match"); } - const newAccount = await ctx.context.internalAdapter.createAccount({ - userId: link.userId, - providerId: provider.providerId, - accountId: userInfo.id, - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - accessTokenExpiresAt: tokens.accessTokenExpiresAt, - refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, - scope: tokens.scopes?.join(","), - }); - if (!newAccount) { - return redirectOnError("unable_to_link_account"); + const existingAccount = + await ctx.context.internalAdapter.findAccount(userInfo.id); + if (existingAccount) { + if (existingAccount.userId !== link.userId) { + return redirectOnError( + "account_already_linked_to_different_user", + ); + } + const updateData = Object.fromEntries( + Object.entries({ + accessToken: tokens.accessToken, + idToken: tokens.idToken, + refreshToken: tokens.refreshToken, + accessTokenExpiresAt: tokens.accessTokenExpiresAt, + refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, + scope: tokens.scopes?.join(","), + }).filter(([_, value]) => value !== undefined), + ); + await ctx.context.internalAdapter.updateAccount( + existingAccount.id, + updateData, + ); + } else { + const newAccount = + await ctx.context.internalAdapter.createAccount({ + userId: link.userId, + providerId: provider.providerId, + accountId: userInfo.id, + accessToken: tokens.accessToken, + accessTokenExpiresAt: tokens.accessTokenExpiresAt, + refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, + scope: tokens.scopes?.join(","), + }); + if (!newAccount) { + return redirectOnError("unable_to_link_account"); + } } let toRedirectTo: string; try {