From c779dfc8fccae70d08efecd42a82d92e415263c0 Mon Sep 17 00:00:00 2001 From: Taesu Date: Wed, 29 Apr 2026 08:09:25 +0900 Subject: [PATCH] feat(google): add `includeGrantedScopes` option to opt out of incremental auth Set `includeGrantedScopes: false` to omit `include_granted_scopes=true` from Google's authorization URL so each OAuth flow requests only its own scopes. Defaults to `true`, preserving current behavior. Closes #9326 Co-authored-by: GautamBytes <161146829+GautamBytes@users.noreply.github.com> --- .changeset/silver-gardens-wander.md | 5 ++ docs/content/docs/authentication/google.mdx | 16 ++++ packages/better-auth/src/social.test.ts | 78 ++++++++++++++++++++ packages/core/src/social-providers/google.ts | 20 ++++- 4 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 .changeset/silver-gardens-wander.md diff --git a/.changeset/silver-gardens-wander.md b/.changeset/silver-gardens-wander.md new file mode 100644 index 0000000000..f21f0d5f59 --- /dev/null +++ b/.changeset/silver-gardens-wander.md @@ -0,0 +1,5 @@ +--- +"@better-auth/core": patch +--- + +Add `includeGrantedScopes` option to the Google provider. Set to `false` to omit `include_granted_scopes=true` from the authorization URL so each OAuth flow requests only its own scopes instead of accumulating prior grants. Defaults to `true`, preserving current behavior. diff --git a/docs/content/docs/authentication/google.mdx b/docs/content/docs/authentication/google.mdx index dd80966b33..bec36b7f6d 100644 --- a/docs/content/docs/authentication/google.mdx +++ b/docs/content/docs/authentication/google.mdx @@ -187,6 +187,22 @@ This will trigger a new OAuth flow that requests the additional scopes. After co provider. +### Disable incremental authorization + +By default, Better Auth sends `include_granted_scopes=true` to Google's authorization endpoint so newly issued access tokens cover scopes from prior grants in addition to the ones requested for the current flow. Set `includeGrantedScopes: false` if each OAuth flow should request only its own scopes. + +```ts +socialProviders: { + google: { + clientId: process.env.GOOGLE_CLIENT_ID as string, + clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, + includeGrantedScopes: false, // [!code highlight] + }, +} +``` + +See Google's [Incremental authorization](https://developers.google.com/identity/protocols/oauth2/web-server#incrementalAuth) docs for the underlying parameter behavior. + ### Always get refresh token Google only issues a refresh token the first time a user consents to your app. diff --git a/packages/better-auth/src/social.test.ts b/packages/better-auth/src/social.test.ts index 2a717a9b22..fc57fc01aa 100644 --- a/packages/better-auth/src/social.test.ts +++ b/packages/better-auth/src/social.test.ts @@ -2652,3 +2652,81 @@ describe("account.scope path-dependent semantics", async () => { ); }); }); + +/** + * @see https://github.com/better-auth/better-auth/issues/9326 + */ +describe("Google Provider — includeGrantedScopes", async () => { + it("includes `include_granted_scopes=true` in the authorization URL by default", async () => { + const { client } = await getTestInstance( + { + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + }, + }, + }, + { disableTestUser: true }, + ); + + const signInRes = await client.signIn.social({ + provider: "google", + callbackURL: "/callback", + }); + + const authUrl = new URL(signInRes.data!.url!); + expect(authUrl.searchParams.get("include_granted_scopes")).toBe("true"); + }); + + it("includes `include_granted_scopes=true` when explicitly enabled", async () => { + const { client } = await getTestInstance( + { + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + includeGrantedScopes: true, + }, + }, + }, + { disableTestUser: true }, + ); + + const signInRes = await client.signIn.social({ + provider: "google", + callbackURL: "/callback", + }); + + const authUrl = new URL(signInRes.data!.url!); + expect(authUrl.searchParams.get("include_granted_scopes")).toBe("true"); + }); + + it("omits `include_granted_scopes` from the authorization URL when disabled", async () => { + const { client } = await getTestInstance( + { + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + includeGrantedScopes: false, + }, + }, + }, + { disableTestUser: true }, + ); + + const signInRes = await client.signIn.social({ + provider: "google", + callbackURL: "/callback", + scopes: ["https://www.googleapis.com/auth/drive.file"], + }); + + const authUrl = new URL(signInRes.data!.url!); + expect(authUrl.searchParams.has("include_granted_scopes")).toBe(false); + expect(authUrl.searchParams.get("scope")).toContain("openid"); + expect(authUrl.searchParams.get("scope")).toContain( + "https://www.googleapis.com/auth/drive.file", + ); + }); +}); diff --git a/packages/core/src/social-providers/google.ts b/packages/core/src/social-providers/google.ts index b85a5b39ee..bb5a033fce 100644 --- a/packages/core/src/social-providers/google.ts +++ b/packages/core/src/social-providers/google.ts @@ -51,6 +51,17 @@ export interface GoogleOptions extends ProviderOptions { * The hosted domain of the user */ hd?: string | undefined; + /** + * Whether to send `include_granted_scopes=true` to Google's authorization + * endpoint, which lets new access tokens cover scopes from prior grants + * in addition to the ones requested for this flow. Set to `false` when + * each OAuth flow should request only its own scopes. + * + * Defaults to `true`. + * + * @see https://developers.google.com/identity/protocols/oauth2/web-server#incrementalAuth + */ + includeGrantedScopes?: boolean | undefined; } export const google = (options: GoogleOptions) => { @@ -92,9 +103,12 @@ export const google = (options: GoogleOptions) => { display: display || options.display, loginHint, hd: options.hd, - additionalParams: { - include_granted_scopes: "true", - }, + additionalParams: + options.includeGrantedScopes === false + ? undefined + : { + include_granted_scopes: "true", + }, }); return url; },