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