mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
fix(oauth): preserve account.scope across re-auth and refresh
`account.scope` is now treated as the user's accumulated granted-scope set rather than the latest token's `scope` claim. linkSocial unions newly granted scopes into the stored value, while sign-in re-authentication and refresh-token responses leave it untouched. Generalizes the Google-specific `include_granted_scopes=true` band-aid from #3013 into a provider-agnostic invariant grounded in RFC 6749 §1.5.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"better-auth": patch
|
||||
"@better-auth/core": patch
|
||||
---
|
||||
|
||||
Preserve previously granted OAuth scopes across sign-in re-authentication and refresh-token requests. `account.scope` now accumulates monotonically: newly granted scopes are merged in only when added via `linkSocial`, and providers returning a narrower scope claim than the user has granted no longer shrink the stored value.
|
||||
@@ -470,6 +470,10 @@ Users already signed in can manually link their account to additional social pro
|
||||
});
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Newly granted scopes are merged into `account.scope`, so prior grants survive incremental authorization. Sign-in re-authentication and refresh-token responses do not modify `account.scope`.
|
||||
</Callout>
|
||||
|
||||
You can also link accounts using ID tokens directly, without redirecting to the provider's OAuth flow:
|
||||
|
||||
```ts
|
||||
|
||||
@@ -800,13 +800,17 @@ export const refreshToken = createAuthEndpoint(
|
||||
tokens.refreshTokenExpiresAt ?? account.refreshTokenExpiresAt;
|
||||
|
||||
if (account.id) {
|
||||
/**
|
||||
* `scope` intentionally omitted. Refresh response may be narrower.
|
||||
*
|
||||
* @see {@link Account.scope}
|
||||
*/
|
||||
const updateData = {
|
||||
...(account || {}),
|
||||
accessToken: await setTokenUtil(tokens.accessToken, ctx.context),
|
||||
refreshToken: resolvedRefreshToken,
|
||||
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt: resolvedRefreshTokenExpiresAt,
|
||||
scope: tokens.scopes?.join(",") || account.scope,
|
||||
idToken: tokens.idToken || account.idToken,
|
||||
};
|
||||
await ctx.context.internalAdapter.updateAccount(account.id, updateData);
|
||||
@@ -823,7 +827,6 @@ export const refreshToken = createAuthEndpoint(
|
||||
refreshToken: resolvedRefreshToken,
|
||||
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt: resolvedRefreshTokenExpiresAt,
|
||||
scope: tokens.scopes?.join(",") || accountData.scope,
|
||||
idToken: tokens.idToken || accountData.idToken,
|
||||
};
|
||||
await setAccountCookie(ctx, updateData);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createAuthEndpoint } from "@better-auth/core/api";
|
||||
import type { OAuth2Tokens } from "@better-auth/core/oauth2";
|
||||
import { mergeScopes } from "@better-auth/core/oauth2";
|
||||
import { safeJSONParse } from "@better-auth/core/utils/json";
|
||||
import * as z from "zod";
|
||||
import { getAwaitableValue } from "../../context/helpers";
|
||||
@@ -195,6 +196,7 @@ export const callbackOAuth = createAuthEndpoint(
|
||||
"account_already_linked_to_different_user",
|
||||
);
|
||||
}
|
||||
const mergedScope = mergeScopes(existingAccount.scope, tokens.scopes);
|
||||
const updateData = Object.fromEntries(
|
||||
Object.entries({
|
||||
accessToken: await setTokenUtil(tokens.accessToken, c.context),
|
||||
@@ -202,7 +204,7 @@ export const callbackOAuth = createAuthEndpoint(
|
||||
idToken: tokens.idToken,
|
||||
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
|
||||
scope: tokens.scopes?.join(","),
|
||||
scope: mergedScope || undefined,
|
||||
}).filter(([_, value]) => value !== undefined),
|
||||
);
|
||||
await c.context.internalAdapter.updateAccount(
|
||||
|
||||
@@ -110,6 +110,11 @@ export async function handleOAuthUserInfo(
|
||||
user =
|
||||
(await applyUpdateUserInfoOnLink(c, dbUser.user.id, userInfo)) ?? user;
|
||||
} else {
|
||||
/**
|
||||
* `scope` intentionally omitted. Updated only via linkSocial.
|
||||
*
|
||||
* @see {@link Account.scope}
|
||||
*/
|
||||
const freshTokens =
|
||||
c.context.options.account?.updateAccountOnSignIn !== false
|
||||
? Object.fromEntries(
|
||||
@@ -122,7 +127,6 @@ export async function handleOAuthUserInfo(
|
||||
),
|
||||
accessTokenExpiresAt: account.accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
|
||||
scope: account.scope,
|
||||
}).filter(([_, value]) => value !== undefined),
|
||||
)
|
||||
: {};
|
||||
|
||||
@@ -2446,3 +2446,209 @@ describe("Railway Provider", async () => {
|
||||
expect(accounts[0]?.accountId).toBe("user_railway_123");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `account.scope` is the user's accumulated grant set: linkSocial unions
|
||||
* new scopes; re-auth and refresh-token responses never narrow the stored
|
||||
* set, regardless of what the provider echoes.
|
||||
*
|
||||
* @see https://github.com/better-auth/better-auth/pull/3013
|
||||
*/
|
||||
describe("account.scope path-dependent semantics", async () => {
|
||||
const { client, cookieSetter, auth } = await getTestInstance(
|
||||
{
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: "test",
|
||||
clientSecret: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ disableTestUser: true },
|
||||
);
|
||||
const ctx = await auth.$context;
|
||||
|
||||
const buildIdToken = (sub: string) =>
|
||||
signJWT(
|
||||
{
|
||||
email: `${sub}@email.com`,
|
||||
email_verified: true,
|
||||
name: "Scope User",
|
||||
picture: "https://test.com/picture.png",
|
||||
exp: 1234567890,
|
||||
sub,
|
||||
iat: 1234567890,
|
||||
aud: "test",
|
||||
azp: "test",
|
||||
nbf: 1234567890,
|
||||
iss: "test",
|
||||
locale: "en",
|
||||
jti: "test",
|
||||
given_name: "Scope",
|
||||
family_name: "User",
|
||||
} satisfies GoogleProfile,
|
||||
DEFAULT_SECRET,
|
||||
);
|
||||
|
||||
function mockGoogleTokenResponse(
|
||||
idToken: string,
|
||||
responseScope: string | undefined,
|
||||
) {
|
||||
mswServer.use(
|
||||
http.post("https://oauth2.googleapis.com/token", () =>
|
||||
HttpResponse.json({
|
||||
access_token: "test-access-token",
|
||||
refresh_token: "test-refresh-token",
|
||||
id_token: idToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
...(responseScope !== undefined ? { scope: responseScope } : {}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function completeSignIn(headers: Headers) {
|
||||
const signInRes = await client.signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/callback",
|
||||
fetchOptions: { onSuccess: cookieSetter(headers) },
|
||||
});
|
||||
const state = new URL(signInRes.data!.url!).searchParams.get("state") || "";
|
||||
await client.$fetch("/callback/google", {
|
||||
query: { state, code: "test" },
|
||||
method: "GET",
|
||||
headers,
|
||||
onError(c) {
|
||||
cookieSetter(headers)(c as any);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("preserves stored scope on sign-in re-authentication when token claim is narrower", async () => {
|
||||
const headers = new Headers();
|
||||
const idToken = await buildIdToken("scope-reauth-sub");
|
||||
|
||||
// Initial sign-in with full granted scope set.
|
||||
mockGoogleTokenResponse(
|
||||
idToken,
|
||||
"openid email profile https://www.googleapis.com/auth/drive.readonly",
|
||||
);
|
||||
await completeSignIn(headers);
|
||||
|
||||
const session = await client.getSession({ fetchOptions: { headers } });
|
||||
const userId = session.data!.user.id;
|
||||
const initial = await ctx.internalAdapter.findAccounts(userId);
|
||||
expect(initial[0]!.scope).toContain(
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
);
|
||||
|
||||
// Re-sign-in echoing only a subset.
|
||||
mockGoogleTokenResponse(idToken, "openid email");
|
||||
await completeSignIn(headers);
|
||||
|
||||
const after = await ctx.internalAdapter.findAccounts(userId);
|
||||
expect(after[0]!.scope).toContain(
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
);
|
||||
});
|
||||
|
||||
it("unions stored scope with newly granted scopes on linkSocial incremental authorization", async () => {
|
||||
const headers = new Headers();
|
||||
const idToken = await buildIdToken("scope-link-sub");
|
||||
|
||||
mockGoogleTokenResponse(idToken, "openid email profile");
|
||||
await completeSignIn(headers);
|
||||
|
||||
const session = await client.getSession({ fetchOptions: { headers } });
|
||||
const userId = session.data!.user.id;
|
||||
const initial = await ctx.internalAdapter.findAccounts(userId);
|
||||
expect(initial[0]!.scope?.split(",").sort()).toEqual([
|
||||
"email",
|
||||
"openid",
|
||||
"profile",
|
||||
]);
|
||||
|
||||
// linkSocial requesting a new scope; provider echoes only that scope.
|
||||
mockGoogleTokenResponse(
|
||||
idToken,
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
);
|
||||
const linkRes = await client.linkSocial(
|
||||
{
|
||||
provider: "google",
|
||||
callbackURL: "/callback",
|
||||
scopes: ["https://www.googleapis.com/auth/drive.readonly"],
|
||||
},
|
||||
{ headers, onSuccess: cookieSetter(headers) },
|
||||
);
|
||||
if (!linkRes.data || !("url" in linkRes.data) || !linkRes.data.url) {
|
||||
throw new Error(
|
||||
`linkSocial did not return an authorization URL: ${JSON.stringify(linkRes)}`,
|
||||
);
|
||||
}
|
||||
const linkState = new URL(linkRes.data.url).searchParams.get("state") || "";
|
||||
await client.$fetch("/callback/google", {
|
||||
query: { state: linkState, code: "test" },
|
||||
method: "GET",
|
||||
headers,
|
||||
onError(c) {
|
||||
cookieSetter(headers)(c as any);
|
||||
},
|
||||
});
|
||||
|
||||
const after = await ctx.internalAdapter.findAccounts(userId);
|
||||
const merged = after[0]!.scope?.split(",") ?? [];
|
||||
expect(merged).toContain("openid");
|
||||
expect(merged).toContain("email");
|
||||
expect(merged).toContain("profile");
|
||||
expect(merged).toContain("https://www.googleapis.com/auth/drive.readonly");
|
||||
});
|
||||
|
||||
it("preserves stored scope on /refresh-token when the refresh response is narrower", async () => {
|
||||
const headers = new Headers();
|
||||
const idToken = await buildIdToken("scope-refresh-sub");
|
||||
|
||||
mockGoogleTokenResponse(
|
||||
idToken,
|
||||
"openid email profile https://www.googleapis.com/auth/drive.readonly",
|
||||
);
|
||||
await completeSignIn(headers);
|
||||
|
||||
const session = await client.getSession({ fetchOptions: { headers } });
|
||||
const userId = session.data!.user.id;
|
||||
const accounts = await ctx.internalAdapter.findAccounts(userId);
|
||||
expect(accounts[0]!.scope).toContain(
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
);
|
||||
|
||||
// Refresh response narrower than stored (RFC 6749 §1.5).
|
||||
mswServer.use(
|
||||
http.post("https://oauth2.googleapis.com/token", () =>
|
||||
HttpResponse.json({
|
||||
access_token: "refreshed-access-token",
|
||||
refresh_token: "refreshed-refresh-token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
scope: "openid email",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await client.$fetch("/refresh-token", {
|
||||
body: {
|
||||
accountId: accounts[0]!.accountId,
|
||||
providerId: "google",
|
||||
},
|
||||
headers,
|
||||
method: "POST",
|
||||
onError(c) {
|
||||
cookieSetter(headers)(c as any);
|
||||
},
|
||||
});
|
||||
|
||||
const after = await ctx.internalAdapter.findAccounts(userId);
|
||||
expect(after[0]!.scope).toContain(
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,10 @@ export const accountSchema = coreSchema.extend({
|
||||
*/
|
||||
refreshTokenExpiresAt: z.date().nullish(),
|
||||
/**
|
||||
* The scopes that the user has authorized
|
||||
* The set of OAuth scopes the user has granted to this account, stored
|
||||
* as a comma-separated list. Represents the accumulated grant rather
|
||||
* than the latest token's `scope` claim, since per RFC 6749 §1.5 a
|
||||
* token's scope may be narrower than the user's grant.
|
||||
*/
|
||||
scope: z.string().nullish(),
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
generateCodeChallenge,
|
||||
getOAuth2Tokens,
|
||||
getPrimaryClientId,
|
||||
mergeScopes,
|
||||
} from "./utils";
|
||||
export {
|
||||
authorizationCodeRequest,
|
||||
|
||||
@@ -47,6 +47,19 @@ export function applyDefaultAccessTokenExpiry(
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the union of stored and incoming OAuth scopes, preserving
|
||||
* stored insertion order and dropping duplicates.
|
||||
*/
|
||||
export function mergeScopes(
|
||||
stored: string | null | undefined,
|
||||
incoming: string[] | undefined,
|
||||
): string {
|
||||
const existing = stored ? stored.split(",").filter(Boolean) : [];
|
||||
const next = incoming ?? [];
|
||||
return [...new Set([...existing, ...next])].join(",");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the provider's primary Client ID: the single string, or the entry at
|
||||
* array index 0 for the cross-platform form used by ID token audience
|
||||
|
||||
Reference in New Issue
Block a user