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:
Taesu
2026-05-31 10:15:50 +01:00
committed by Gustavo Valverde
parent df9e8b2951
commit af95f0d305
9 changed files with 247 additions and 5 deletions
+6
View File
@@ -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),
)
: {};
+206
View File
@@ -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",
);
});
});
+4 -1
View File
@@ -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(),
/**
+1
View File
@@ -20,6 +20,7 @@ export {
generateCodeChallenge,
getOAuth2Tokens,
getPrimaryClientId,
mergeScopes,
} from "./utils";
export {
authorizationCodeRequest,
+13
View File
@@ -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