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

View File

@@ -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 () => {

View File

@@ -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({

View File

@@ -134,8 +134,21 @@ 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,
@@ -146,11 +159,10 @@ export const callbackOAuth = createAuthEndpoint(
},
c,
);
if (!newAccount) {
return redirectOnError("unable_to_link_account");
}
}
let toRedirectTo: string;
try {
const url = callbackURL;

View File

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

View File

@@ -620,12 +620,35 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
) {
return redirectOnError("email_doesn't_match");
}
const newAccount = await ctx.context.internalAdapter.createAccount({
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,
refreshToken: tokens.refreshToken,
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
scope: tokens.scopes?.join(","),
@@ -633,6 +656,7 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
if (!newAccount) {
return redirectOnError("unable_to_link_account");
}
}
let toRedirectTo: string;
try {
const url = callbackURL;