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 <bekacru@gmail.com>
This commit is contained in:
Léo LEDUCQ
2025-04-11 12:54:30 +02:00
committed by GitHub
parent 8643cb6d13
commit c14f127fd3
6 changed files with 114 additions and 33 deletions

View File

@@ -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. 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" ```ts title="auth.ts"
export const auth = betterAuth({ export const auth = betterAuth({

View File

@@ -120,6 +120,30 @@ describe("account", async () => {
expect(accounts.data?.length).toBe(2); 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 () => { it("should link second account from the same provider", async () => {
const { headers: headers2 } = await signInWithTestUser(); const { headers: headers2 } = await signInWithTestUser();
const linkAccountRes = await client.linkSocial( const linkAccountRes = await client.linkSocial(
@@ -166,7 +190,7 @@ describe("account", async () => {
const accounts = await client.listAccounts({ const accounts = await client.listAccounts({
fetchOptions: { headers: headers3 }, fetchOptions: { headers: headers3 },
}); });
expect(accounts.data?.length).toBe(3); expect(accounts.data?.length).toBe(2);
}); });
it("should unlink account", async () => { it("should unlink account", async () => {
const { headers } = await signInWithTestUser(); const { headers } = await signInWithTestUser();
@@ -175,7 +199,7 @@ describe("account", async () => {
headers, headers,
}, },
}); });
expect(previousAccounts.data?.length).toBe(3); expect(previousAccounts.data?.length).toBe(2);
const unlinkAccountId = previousAccounts.data![1].accountId; const unlinkAccountId = previousAccounts.data![1].accountId;
const unlinkRes = await client.unlinkAccount({ const unlinkRes = await client.unlinkAccount({
providerId: "google", providerId: "google",
@@ -190,7 +214,7 @@ describe("account", async () => {
headers, 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 () => { it("should fail to unlink the last account of a provider", async () => {

View File

@@ -102,6 +102,16 @@ export const linkSocialAccount = createAuthEndpoint(
provider: z.enum(socialProviderList, { provider: z.enum(socialProviderList, {
description: "The OAuth2 provider to use", 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], use: [sessionMiddleware],
metadata: { metadata: {
@@ -163,6 +173,7 @@ export const linkSocialAccount = createAuthEndpoint(
state: state.state, state: state.state,
codeVerifier: state.codeVerifier, codeVerifier: state.codeVerifier,
redirectURI: `${c.context.baseURL}/callback/${provider.id}`, redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
scopes: c.body.scopes,
}); });
return c.json({ return c.json({

View File

@@ -134,23 +134,35 @@ export const callbackOAuth = createAuthEndpoint(
if (existingAccount.userId.toString() !== link.userId.toString()) { if (existingAccount.userId.toString() !== link.userId.toString()) {
return redirectOnError("account_already_linked_to_different_user"); 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; let toRedirectTo: string;
try { try {
const url = callbackURL; const url = callbackURL;

View File

@@ -750,13 +750,13 @@ export const createInternalAdapter = (
return account; return account;
}, },
updateAccount: async ( updateAccount: async (
accountId: string, id: string,
data: Partial<Account>, data: Partial<Account>,
context?: GenericEndpointContext, context?: GenericEndpointContext,
) => { ) => {
const account = await updateWithHooks<Account>( const account = await updateWithHooks<Account>(
data, data,
[{ field: "id", value: accountId }], [{ field: "id", value: id }],
"account", "account",
undefined, undefined,
context, context,

View File

@@ -620,18 +620,42 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
) { ) {
return redirectOnError("email_doesn't_match"); return redirectOnError("email_doesn't_match");
} }
const newAccount = await ctx.context.internalAdapter.createAccount({ const existingAccount =
userId: link.userId, await ctx.context.internalAdapter.findAccount(userInfo.id);
providerId: provider.providerId, if (existingAccount) {
accountId: userInfo.id, if (existingAccount.userId !== link.userId) {
accessToken: tokens.accessToken, return redirectOnError(
refreshToken: tokens.refreshToken, "account_already_linked_to_different_user",
accessTokenExpiresAt: tokens.accessTokenExpiresAt, );
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, }
scope: tokens.scopes?.join(","), const updateData = Object.fromEntries(
}); Object.entries({
if (!newAccount) { accessToken: tokens.accessToken,
return redirectOnError("unable_to_link_account"); 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; let toRedirectTo: string;
try { try {