diff --git a/.changeset/quiet-pandas-merge.md b/.changeset/quiet-pandas-merge.md new file mode 100644 index 0000000000..756d22daab --- /dev/null +++ b/.changeset/quiet-pandas-merge.md @@ -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. diff --git a/docs/content/docs/concepts/users-accounts.mdx b/docs/content/docs/concepts/users-accounts.mdx index 75641861d6..ee4a86f1f3 100644 --- a/docs/content/docs/concepts/users-accounts.mdx +++ b/docs/content/docs/concepts/users-accounts.mdx @@ -470,6 +470,10 @@ Users already signed in can manually link their account to additional social pro }); ``` + + 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`. + + You can also link accounts using ID tokens directly, without redirecting to the provider's OAuth flow: ```ts diff --git a/packages/better-auth/src/api/routes/account.ts b/packages/better-auth/src/api/routes/account.ts index af8c8edbf7..c6478adaa0 100644 --- a/packages/better-auth/src/api/routes/account.ts +++ b/packages/better-auth/src/api/routes/account.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); diff --git a/packages/better-auth/src/api/routes/callback.ts b/packages/better-auth/src/api/routes/callback.ts index 6796ac3d7e..65c7bba616 100644 --- a/packages/better-auth/src/api/routes/callback.ts +++ b/packages/better-auth/src/api/routes/callback.ts @@ -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( diff --git a/packages/better-auth/src/oauth2/link-account.ts b/packages/better-auth/src/oauth2/link-account.ts index 978c5c65b3..dfb70c7bb2 100644 --- a/packages/better-auth/src/oauth2/link-account.ts +++ b/packages/better-auth/src/oauth2/link-account.ts @@ -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), ) : {}; diff --git a/packages/better-auth/src/social.test.ts b/packages/better-auth/src/social.test.ts index 5db22f0699..2a717a9b22 100644 --- a/packages/better-auth/src/social.test.ts +++ b/packages/better-auth/src/social.test.ts @@ -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", + ); + }); +}); diff --git a/packages/core/src/db/schema/account.ts b/packages/core/src/db/schema/account.ts index 71bd8920ad..811ab63ab7 100644 --- a/packages/core/src/db/schema/account.ts +++ b/packages/core/src/db/schema/account.ts @@ -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(), /** diff --git a/packages/core/src/oauth2/index.ts b/packages/core/src/oauth2/index.ts index 674f5aad72..53b8e9275d 100644 --- a/packages/core/src/oauth2/index.ts +++ b/packages/core/src/oauth2/index.ts @@ -20,6 +20,7 @@ export { generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId, + mergeScopes, } from "./utils"; export { authorizationCodeRequest, diff --git a/packages/core/src/oauth2/utils.ts b/packages/core/src/oauth2/utils.ts index b7e787ab66..a26daa7540 100644 --- a/packages/core/src/oauth2/utils.ts +++ b/packages/core/src/oauth2/utils.ts @@ -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