History

Fix HubSpot optional scopes for workspace OAuth (#1898)

* Fix HubSpot optional scopes for workspace OAuth * chore: rerun flaky cloud E2E * Honor integration-declared HubSpot optional scopes * Test queue timeout with a controlled clock * Verify optional OAuth scopes through consent and tool execution ---------

Lloyd Vickery committed 1 month ago

Commit 65d939e1 parent 5ecb8819 files · +347 −21Browse files

Unified · Split

9 files
  1. .changeset/hubspot-workspace-optional-scopes.md +5 −0
  2. apps/cloud/src/engine/first-party-oauth-clients.ts +1 −6
  3. e2e/selfhost/oauth-optional-scopes.test.ts +172 −0
  4. packages/core/sdk/src/executor.ts +9 −1
  5. packages/core/sdk/src/host-internal.ts +5 −1
  6. packages/core/sdk/src/oauth-helpers.test.ts +20 −2
  7. packages/core/sdk/src/oauth-helpers.ts +34 −1
  8. packages/core/sdk/src/oauth-scope-union.test.ts +75 −6
  9. packages/core/sdk/src/oauth-service.ts +26 −4
.changeset/hubspot-workspace-optional-scopes.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"@executor-js/sdk": patch
3+---
4+
5+Send HubSpot optional permissions in `optional_scope` for workspace OAuth clients so accounts can connect without optional product features.
apps/cloud/src/engine/first-party-oauth-clients.ts+1−6
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 } from "@executor-js/plugin-openapi/providers/microsoft";
77 import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
88 import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk";
9+import { HUBSPOT_OPTIONAL_SCOPES } from "@executor-js/sdk/host-internal";
910
1011 import { makeGoogleOAuthListing } from "../analytics/google-oauth-listing";
1112 import { POSTHOG_INGEST_HOST } from "../edge/passthrough";
@@ -152,12 +153,6 @@ const HUBSPOT_REQUIRED_SCOPES = [ · 140 unmodified lines
152153 "timeline",
153154 ] as const;
154155
155−const HUBSPOT_OPTIONAL_SCOPES = [
156− "content",
157− "crm.objects.custom.read",
158− "crm.schemas.custom.read",
159−] as const;
160−
161156 const MICROSOFT_SCOPES = [
162157 "User.Read",
163158 "Calendars.ReadWrite",
e2e/selfhost/oauth-optional-scopes.test.ts+172−0
@@ -0,0 +1,172 @@
1+import { randomBytes } from "node:crypto";
2+
3+import { expect } from "@effect/vitest";
4+import { connectEmulator } from "@executor-js/emulate";
5+import { Effect } from "effect";
6+import { composePluginApi } from "@executor-js/api/server";
7+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
8+import {
9+ AuthTemplateSlug,
10+ ConnectionName,
11+ IntegrationSlug,
12+ OAuthClientSlug,
13+} from "@executor-js/sdk/shared";
14+
15+import { createEmulatorInstance } from "../src/emulator-instance";
16+import { scenario } from "../src/scenario";
17+import { Api, Browser, Mcp, Target } from "../src/services";
18+
19+const api = composePluginApi([openApiHttpPlugin()] as const);
20+
21+scenario(
22+ "OAuth optional scopes · the integration partitions scopes and completes an authenticated connection",
23+ { timeout: 180_000 },
24+ Effect.scoped(
25+ Effect.gen(function* () {
26+ const target = yield* Target;
27+ const browser = yield* Browser;
28+ const mcp = yield* Mcp;
29+ const { client: makeClient } = yield* Api;
30+ const identity = yield* target.newIdentity();
31+ const client = yield* makeClient(api, identity);
32+ const session = mcp.session(identity);
33+ expect((yield* session.call("execute", { code: "return true;" })).ok).toBe(true);
34+ const base = yield* createEmulatorInstance("github", "optional-scopes");
35+ const emulator = yield* Effect.promise(() =>
36+ connectEmulator({ baseUrl: base, service: "github" }),
37+ );
38+ yield* Effect.promise(() => emulator.seed({ users: [{ login: "optional-scope-user" }] }));
39+ const slug = IntegrationSlug.make(`optional-${randomBytes(4).toString("hex")}`);
40+ const app = OAuthClientSlug.make(`${slug}-app`);
41+ yield* Effect.addFinalizer(() =>
42+ client.openapi.removeSpec({ params: { slug } }).pipe(Effect.orDie),
43+ );
44+ yield* Effect.addFinalizer(() =>
45+ client.oauth
46+ .removeClient({ params: { slug: app }, payload: { owner: "org" } })
47+ .pipe(Effect.orDie),
48+ );
49+ // GitHub supplies the real OAuth transport and protected resource. The test
50+ // verifies Executor's partitioning contract; it does not claim that GitHub
51+ // implements HubSpot's optional-grant policy.
52+ const authorizationUrl = `${base}/login/oauth/authorize`;
53+ const tokenUrl = `${base}/login/oauth/access_token`;
54+ yield* client.openapi.addSpec({
55+ payload: {
56+ slug,
57+ baseUrl: base,
58+ spec: {
59+ kind: "blob",
60+ value: JSON.stringify({
61+ openapi: "3.0.3",
62+ info: { title: "Optional scope API", version: "1" },
63+ paths: {
64+ "/user": {
65+ get: {
66+ operationId: "getUser",
67+ security: [{ oauth: ["read:user"] }],
68+ responses: { "200": { description: "Authenticated user" } },
69+ },
70+ },
71+ },
72+ components: {
73+ securitySchemes: {
74+ oauth: {
75+ type: "oauth2",
76+ flows: {
77+ authorizationCode: {
78+ authorizationUrl,
79+ tokenUrl,
80+ scopes: {
81+ "read:user": "Read user",
82+ "user:email": "Read email when granted",
83+ },
84+ },
85+ },
86+ },
87+ },
88+ },
89+ }),
90+ },
91+ authenticationTemplate: [
92+ {
93+ slug: "oauth",
94+ kind: "oauth2",
95+ authorizationUrl: `${authorizationUrl}?optional_scope=user%3Aemail`,
96+ tokenUrl,
97+ scopes: ["read:user", "user:email"],
98+ },
99+ ],
100+ },
101+ });
102+ yield* client.oauth.createClient({
103+ payload: {
104+ owner: "org",
105+ slug: app,
106+ grant: "authorization_code",
107+ authorizationUrl,
108+ tokenUrl,
109+ clientId: "optional-test-client",
110+ clientSecret: "optional-test-secret",
111+ originIntegration: slug,
112+ },
113+ });
114+ const started = yield* client.oauth.start({
115+ payload: {
116+ owner: "org",
117+ client: app,
118+ clientOwner: "org",
119+ name: ConnectionName.make("main"),
120+ integration: slug,
121+ template: AuthTemplateSlug.make("oauth"),
122+ },
123+ });
124+ if (started.status !== "redirect")
125+ return yield* Effect.die("Expected authorization redirect");
126+
127+ yield* Effect.addFinalizer(() =>
128+ client.oauth.cancel({ payload: { state: started.state } }).pipe(Effect.orDie),
129+ );
130+ const url = new URL(started.authorizationUrl);
131+ expect(url.searchParams.get("scope")).toBe("read:user");
132+ expect(url.searchParams.get("optional_scope")).toBe("user:email");
133+ yield* browser.session(identity, async ({ page, step }) => {
134+ await step("Review and approve the OAuth consent request", async () => {
135+ await page.goto(started.authorizationUrl);
136+ await page.getByRole("button", { name: /optional-scope-user/ }).click();
137+ await page.getByText("Connected", { exact: true }).waitFor({ timeout: 30_000 });
138+ });
139+ });
140+ yield* Effect.addFinalizer(() =>
141+ client.connections
142+ .remove({
143+ params: { owner: "org", integration: slug, name: ConnectionName.make("main") },
144+ })
145+ .pipe(Effect.orDie),
146+ );
147+ const catalog = yield* client.tools.list({ query: { integration: slug } });
148+ const tool = catalog.find((entry) => entry.name.endsWith("getUser"));
149+ if (!tool)
150+ return yield* Effect.die(
151+ `Authenticated getUser tool missing: ${catalog.map((entry) => entry.name).join(", ")}`,
152+ );
153+ let result = yield* session.call("execute", {
154+ code: `const path = ${JSON.stringify(String(tool.address))}.split(".").slice(1); let call = tools; for (const part of path) call = call[part]; return await call({});`,
155+ });
156+ for (let attempts = 0; result.text.includes("executionId:") && attempts < 10; attempts += 1)
157+ result = yield* session.approvePaused(result.text);
158+ expect(result.ok).toBe(true);
159+ expect(result.text).toContain("optional-scope-user");
160+ const ledger = yield* Effect.promise(() => emulator.ledger.list());
161+ const authorize = ledger.find(
162+ (entry) => entry.method === "GET" && entry.path.endsWith("/login/oauth/authorize"),
163+ );
164+ expect(new URLSearchParams(authorize?.query).get("optional_scope")).toBe("user:email");
165+ expect(
166+ ledger
167+ .filter((entry) => entry.path === "/user" && entry.response.status === 200)
168+ .map((entry) => entry.identity.user?.login),
169+ ).toContain("optional-scope-user");
170+ }),
171+ ),
172+);
packages/core/sdk/src/executor.ts+9−1
@@ -208,6 +208,7 @@ import { · 207 unmodified lines
208208 exchangeClientCredentials,
209209 isPermanentTokenRejection,
210210 isUnusableSuccessTokenResponse,
211+ optionalScopesFromAuthorizationUrl,
211212 shouldRefreshToken,
212213 type OAuth2TokenResponse,
213214 type OAuthEndpointUrlPolicy,
@@ -6711,7 +6712,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 6497 unmodified lines
67116712 discoveryUrl: oauth.discoveryUrl,
67126713 } satisfies OAuthScopePolicy;
67136714 }
6714− return { kind: "scopes", scopes: oauth?.scopes ?? [] } satisfies OAuthScopePolicy;
6715+ return {
6716+ kind: "scopes",
6717+ scopes: oauth?.scopes ?? [],
6718+ optionalScopes:
6719+ oauth?.authorizationUrl === undefined
6720+ ? []
6721+ : optionalScopesFromAuthorizationUrl(oauth.authorizationUrl),
6722+ } satisfies OAuthScopePolicy;
67156723 }),
67166724 httpClientLayer: config.httpClientLayer,
67176725 fetch: config.fetch,
packages/core/sdk/src/host-internal.ts+5−1
@@ -37,7 +37,11 @@ export { · 36 unmodified lines
3737 type HostedHttpClientOptions,
3838 } from "./hosted-http-client";
3939
40−export { OAUTH2_DEFAULT_TIMEOUT_MS, assertSupportedOAuthEndpointUrl } from "./oauth-helpers";
40+export {
41+ HUBSPOT_OPTIONAL_SCOPES,
42+ OAUTH2_DEFAULT_TIMEOUT_MS,
43+ assertSupportedOAuthEndpointUrl,
44+} from "./oauth-helpers";
4145
4246 export {
4347 DEFAULT_SUBJECT_LAST_SEEN_THROTTLE_MS,
packages/core/sdk/src/oauth-helpers.test.ts+20−2
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
2525 idTokenIdentityLabel,
2626 isPermanentTokenRejection,
2727 isUnusableSuccessTokenResponse,
28+ optionalScopesFromAuthorizationUrl,
2829 refreshAccessToken,
2930 shouldRefreshToken,
3031 } from "./oauth-helpers";
@@ -193,16 +194,33 @@ describe("PKCE", () => { · 162 unmodified lines
193194 // buildAuthorizationUrl
194195 // ---------------------------------------------------------------------------
195196
196−describe("providerAuthorizeExtras (Google offline/consent quirk)", () => {
197+describe("providerAuthorizeExtras (provider authorization quirks)", () => {
197198 it("adds access_type=offline + prompt=consent for the Google authorize host", () => {
198199 expect(providerAuthorizeExtras("https://accounts.google.com/o/oauth2/v2/auth")).toEqual({
199200 access_type: "offline",
200201 prompt: "consent",
201202 });
202203 });
203− it("adds nothing for non-Google hosts or an unparseable URL (token host ≠ authorize host)", () => {
204+
205+ it("adds optional_scope for workspace-owned HubSpot OAuth clients", () => {
206+ expect(providerAuthorizeExtras("https://app.hubspot.com/oauth/authorize")).toEqual({
207+ optional_scope: "content crm.objects.custom.read crm.schemas.custom.read",
208+ });
209+ });
210+
211+ it("reads integration-declared optional_scope values from an authorization URL", () => {
212+ expect(
213+ optionalScopesFromAuthorizationUrl(
214+ "https://app.hubspot.com/oauth/authorize?optional_scope=crm.objects.contacts.read+crm.objects.contacts.write+crm.objects.contacts.read",
215+ ),
216+ ).toEqual(["crm.objects.contacts.read", "crm.objects.contacts.write"]);
217+ expect(optionalScopesFromAuthorizationUrl("not a url")).toEqual([]);
218+ });
219+
220+ it("adds nothing for unrelated hosts, token hosts, or an unparseable URL", () => {
204221 expect(providerAuthorizeExtras("https://accounts.spotify.com/authorize")).toEqual({});
205222 expect(providerAuthorizeExtras("https://oauth2.googleapis.com/token")).toEqual({});
223+ expect(providerAuthorizeExtras("https://api.hubapi.com/oauth/v3/token")).toEqual({});
206224 expect(providerAuthorizeExtras("not a url")).toEqual({});
207225 });
208226 });
packages/core/sdk/src/oauth-helpers.ts+34−1
@@ -113,6 +113,16 @@ export const OAUTH2_REFRESH_SKEW_MS = 60_000; · 112 unmodified lines
113113 /** Default token-endpoint timeout. */
114114 export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000;
115115
116+/** HubSpot scopes that the registered app may grant but must receive through
117+ * HubSpot's non-standard `optional_scope` authorize parameter. Keeping these
118+ * out of the RFC `scope` parameter lets accounts without the corresponding
119+ * product features complete consent while still granting them when present. */
120+export const HUBSPOT_OPTIONAL_SCOPES = [
121+ "content",
122+ "crm.objects.custom.read",
123+ "crm.schemas.custom.read",
124+] as const;
125+
116126 /** RFC 8693 §2.1 token-exchange grant. */
117127 export const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";
118128
@@ -247,7 +257,12 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string · 128 unmodified lines
247257 * re-consent can silently keep the old scope set. Do not add
248258 * `include_granted_scopes=true` here: with historical grants on the same Google
249259 * consent app, Google folds those unrelated scopes into the new consent flow and
250− * can fail inside accounts.google.com before returning to our callback. */
260+ * can fail inside accounts.google.com before returning to our callback.
261+ *
262+ * HubSpot: app scopes marked optional are ignored when they are omitted from
263+ * the provider-specific `optional_scope` parameter. The OpenAPI auth template
264+ * can only declare RFC scopes, so this host-level quirk must apply to both
265+ * first-party and workspace-owned HubSpot OAuth clients. */
251266 export const providerAuthorizeExtras = (
252267 authorizationUrl: string,
253268 ): Readonly<Record<string, string>> => {
@@ -257,12 +272,30 @@ export const providerAuthorizeExtras = ( · 3 unmodified lines
257272 if (host === "accounts.google.com") {
258273 return { access_type: "offline", prompt: "consent" };
259274 }
275+ if (host === "app.hubspot.com") {
276+ return { optional_scope: HUBSPOT_OPTIONAL_SCOPES.join(" ") };
277+ }
260278 } catch {
261279 // Unparseable authorization URL — let buildAuthorizationUrl surface the error.
262280 }
263281 return {};
264282 };
265283
284+/** Provider-specific scopes embedded in an integration's authorization
285+ * endpoint. HubSpot models app-optional permissions with the non-standard
286+ * `optional_scope` query parameter, so they are part of the integration's
287+ * request contract rather than the registered OAuth app identity. */
288+export const optionalScopesFromAuthorizationUrl = (authorizationUrl: string): readonly string[] => {
289+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input -> no optional scopes
290+ try {
291+ const value = new URL(authorizationUrl).searchParams.get("optional_scope");
292+ if (value == null) return [];
293+ return [...new Set(value.split(/\s+/).filter(Boolean))];
294+ } catch {
295+ return [];
296+ }
297+};
298+
266299 // ---------------------------------------------------------------------------
267300 // Regional token-endpoint rebind
268301 //
packages/core/sdk/src/oauth-scope-union.test.ts+75−6
@@ -37,7 +37,10 @@ const DECLARED_SCOPES = ["calendar", "gmail", "drive", "sheets"] as const; · 36 unmodified lines
3737 * scopes (the MCP/no-template-scopes case). */
3838 const makeScopePluginWithId = <const TId extends string>(
3939 id: TId,
40− config: { readonly scopes: readonly string[] | null },
40+ config: {
41+ readonly scopes: readonly string[] | null;
42+ readonly authorizationUrl?: string;
43+ },
4144 options: { readonly discoversScopes?: boolean; readonly discoveryUrl?: string } = {},
4245 ) =>
4346 definePlugin(() => ({
@@ -49,7 +52,10 @@ const makeScopePluginWithId = <const TId extends string>( · 5 unmodified lines
4952 }),
5053 invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }),
5154 describeAuthMethods: (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {
52− const cfg = record.config as { readonly scopes?: readonly string[] | null } | null;
55+ const cfg = record.config as {
56+ readonly scopes?: readonly string[] | null;
57+ readonly authorizationUrl?: string;
58+ } | null;
5359 const scopes = cfg?.scopes;
5460 if (scopes == null) {
5561 // No declared oauth scopes. Server-targeting methods (MCP) expose a
@@ -73,7 +79,12 @@ const makeScopePluginWithId = <const TId extends string>( · 17 unmodified lines
7379 label: "OAuth2",
7480 kind: "oauth",
7581 template: String(TEMPLATE),
76− oauth: { scopes },
82+ oauth: {
83+ scopes,
84+ ...(cfg?.authorizationUrl === undefined
85+ ? {}
86+ : { authorizationUrl: cfg.authorizationUrl }),
87+ },
7788 },
7889 ];
7990 },
@@ -82,13 +93,20 @@ const makeScopePluginWithId = <const TId extends string>( · 2 unmodified lines
8293 ctx.core.integrations.register({
8394 slug: INTEG,
8495 description: "Acme",
85− config: { scopes: config.scopes },
96+ config: {
97+ scopes: config.scopes,
98+ ...(config.authorizationUrl === undefined
99+ ? {}
100+ : { authorizationUrl: config.authorizationUrl }),
101+ },
86102 }),
87103 }),
88104 }))();
89105
90−const makeScopePlugin = (config: { readonly scopes: readonly string[] | null }) =>
91− makeScopePluginWithId("acme", config);
106+const makeScopePlugin = (config: {
107+ readonly scopes: readonly string[] | null;
108+ readonly authorizationUrl?: string;
109+}) => makeScopePluginWithId("acme", config);
92110
93111 const makeMcpScopePlugin = (config: { readonly scopes: readonly string[] | null }) =>
94112 makeScopePluginWithId("mcp", config, { discoversScopes: true });
@@ -226,6 +244,57 @@ describe("oauth.start integration-driven scopes", () => { · 131 unmodified lines
226244 ),
227245 );
228246
247+ it.effect("moves integration-declared optional scopes out of scope into optional_scope", () =>
248+ Effect.scoped(
249+ Effect.gen(function* () {
250+ const declared = [
251+ "oauth",
252+ "crm.objects.contacts.read",
253+ "crm.objects.companies.read",
254+ ] as const;
255+ const optional = [
256+ "crm.objects.contacts.read",
257+ "crm.objects.companies.read",
258+ "content",
259+ ] as const;
260+ const server = yield* serveOAuthTestServer({ scopes: [...declared] });
261+ const plugins = [
262+ memoryCredentialsPlugin(),
263+ makeScopePlugin({
264+ scopes: declared,
265+ authorizationUrl: `${server.authorizationEndpoint}?optional_scope=${optional.join("+")}`,
266+ }),
267+ ] as const;
268+ const { executor } = yield* makeTestWorkspaceHarness({ plugins });
269+ yield* executor.acme.seed();
270+ yield* executor.oauth.createClient({
271+ owner: "org",
272+ slug: CLIENT,
273+ authorizationUrl: server.authorizationEndpoint,
274+ tokenUrl: server.tokenEndpoint,
275+ grant: "authorization_code",
276+ clientId: "test-client",
277+ clientSecret: "test-secret",
278+ });
279+
280+ const started = yield* executor.oauth.start({
281+ owner: "org",
282+ client: CLIENT,
283+ clientOwner: "org",
284+ name: ConnectionName.make("main"),
285+ integration: INTEG,
286+ template: TEMPLATE,
287+ });
288+ expect(started.status).toBe("redirect");
289+ if (started.status !== "redirect") return;
290+
291+ const url = new URL(started.authorizationUrl);
292+ expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["oauth"]);
293+ expect(url.searchParams.get("optional_scope")?.split(/\s+/)).toEqual(optional);
294+ }),
295+ ),
296+ );
297+
229298 it.effect("filters stale declared scopes against authorization-server metadata", () =>
230299 Effect.scoped(
231300 Effect.gen(function* () {
packages/core/sdk/src/oauth-service.ts+26−4
@@ -199,7 +199,14 @@ const startErrorFromEnterpriseManaged = (cause: EnterpriseManagedMintError): OAu · 198 unmodified lines
199199 * 8707 resource: a user may clear the client's resource (Entra v2 rejects
200200 * the parameter, #1789) without losing scope discovery. */
201201 export type OAuthScopePolicy =
202− | { readonly kind: "scopes"; readonly scopes: readonly string[] }
202+ | {
203+ readonly kind: "scopes";
204+ readonly scopes: readonly string[];
205+ /** Provider-specific scopes declared on the integration's authorization
206+ * endpoint (HubSpot `optional_scope`). These must not also be sent in
207+ * the RFC `scope` parameter. */
208+ readonly optionalScopes?: readonly string[];
209+ }
203210 | { readonly kind: "discover"; readonly discoveryUrl: string };
204211
205212 /** Everything the OAuth service needs from the executor: fuma access for the
@@ -2006,11 +2013,23 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1800 unmodified lines
20062013 : scopePolicy.kind === "discover"
20072014 ? requestedScopes
20082015 : yield* filterAuthorizationCodeScopes(client, requestedScopes);
2016+ const providerExtras = providerAuthorizeExtras(client.authorizationUrl);
2017+ const workspaceOptionalScopes = firstPartyFlow
2018+ ? []
2019+ : dedupeScopes([
2020+ ...(providerExtras.optional_scope ?? "").split(/\s+/).filter(Boolean),
2021+ ...(scopePolicy.kind === "scopes" ? (scopePolicy.optionalScopes ?? []) : []),
2022+ ]);
2023+ const workspaceOptionalScopeSet = new Set(workspaceOptionalScopes);
20092024 const completeAuthorizationScopes = dedupeScopes([
2010− ...authorizationRequestedScopes,
2025+ ...authorizationRequestedScopes.filter((scope) => !workspaceOptionalScopeSet.has(scope)),
20112026 ...(firstParty?.additionalAuthorizationScopes ?? []),
20122027 ...additionalAuthorizationLifecycleScopes(client),
20132028 ]);
2029+ const completeRequestedScopes = dedupeScopes([
2030+ ...completeAuthorizationScopes,
2031+ ...workspaceOptionalScopes,
2032+ ]);
20142033
20152034 // authorization_code: persist a session + build the authorize URL.
20162035 const verifier = createPkceCodeVerifier();
@@ -2072,7 +2091,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines
20722091 payload: {
20732092 owner: input.owner,
20742093 clientOwner: input.clientOwner,
2075− requestedScopes: completeAuthorizationScopes,
2094+ requestedScopes: completeRequestedScopes,
20762095 },
20772096 expires_at: expiresAt,
20782097 created_at: now,
@@ -2094,7 +2113,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 15 unmodified lines
20942113 // without these Google returns no refresh token and won't re-consent
20952114 // to widen scopes on reconnect.
20962115 extraParams: {
2097− ...providerAuthorizeExtras(client.authorizationUrl),
2116+ ...providerExtras,
2117+ ...(workspaceOptionalScopes.length > 0
2118+ ? { optional_scope: workspaceOptionalScopes.join(" ") }
2119+ : {}),
20982120 ...(firstParty?.authorizationExtraParams ?? {}),
20992121 },
21002122 endpointUrlPolicy: deps.endpointUrlPolicy,