mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-10 04:19:32 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user