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
5Send 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
6} from "@executor-js/plugin-openapi/providers/microsoft";6} from "@executor-js/plugin-openapi/providers/microsoft";
7import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";7import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
8import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk";8import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk";
9import { HUBSPOT_OPTIONAL_SCOPES } from "@executor-js/sdk/host-internal";
9 10
10import { makeGoogleOAuthListing } from "../analytics/google-oauth-listing";11import { makeGoogleOAuthListing } from "../analytics/google-oauth-listing";
11import { POSTHOG_INGEST_HOST } from "../edge/passthrough";12import { POSTHOG_INGEST_HOST } from "../edge/passthrough";
@@ -152,12 +153,6 @@ const HUBSPOT_REQUIRED_SCOPES = [ · 140 unmodified lines
152 "timeline",153 "timeline",
153] as const;154] as const;
154 155
155const HUBSPOT_OPTIONAL_SCOPES = [
156 "content",
157 "crm.objects.custom.read",
158 "crm.schemas.custom.read",
159] as const;
160
161const MICROSOFT_SCOPES = [156const MICROSOFT_SCOPES = [
162 "User.Read",157 "User.Read",
163 "Calendars.ReadWrite",158 "Calendars.ReadWrite",
e2e/selfhost/oauth-optional-scopes.test.ts+172−0
@@ -0,0 +1,172 @@
1import { randomBytes } from "node:crypto";
2
3import { expect } from "@effect/vitest";
4import { connectEmulator } from "@executor-js/emulate";
5import { Effect } from "effect";
6import { composePluginApi } from "@executor-js/api/server";
7import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
8import {
9 AuthTemplateSlug,
10 ConnectionName,
11 IntegrationSlug,
12 OAuthClientSlug,
13} from "@executor-js/sdk/shared";
14
15import { createEmulatorInstance } from "../src/emulator-instance";
16import { scenario } from "../src/scenario";
17import { Api, Browser, Mcp, Target } from "../src/services";
18
19const api = composePluginApi([openApiHttpPlugin()] as const);
20
21scenario(
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
208 exchangeClientCredentials,208 exchangeClientCredentials,
209 isPermanentTokenRejection,209 isPermanentTokenRejection,
210 isUnusableSuccessTokenResponse,210 isUnusableSuccessTokenResponse,
211 optionalScopesFromAuthorizationUrl,
211 shouldRefreshToken,212 shouldRefreshToken,
212 type OAuth2TokenResponse,213 type OAuth2TokenResponse,
213 type OAuthEndpointUrlPolicy,214 type OAuthEndpointUrlPolicy,
@@ -6711,7 +6712,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 6497 unmodified lines
6711 discoveryUrl: oauth.discoveryUrl,6712 discoveryUrl: oauth.discoveryUrl,
6712 } satisfies OAuthScopePolicy;6713 } satisfies OAuthScopePolicy;
6713 }6714 }
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;
6715 }),6723 }),
6716 httpClientLayer: config.httpClientLayer,6724 httpClientLayer: config.httpClientLayer,
6717 fetch: config.fetch,6725 fetch: config.fetch,
packages/core/sdk/src/host-internal.ts+5−1
@@ -37,7 +37,11 @@ export { · 36 unmodified lines
37 type HostedHttpClientOptions,37 type HostedHttpClientOptions,
38} from "./hosted-http-client";38} from "./hosted-http-client";
39 39
40export { OAUTH2_DEFAULT_TIMEOUT_MS, assertSupportedOAuthEndpointUrl } from "./oauth-helpers";40export {
41 HUBSPOT_OPTIONAL_SCOPES,
42 OAUTH2_DEFAULT_TIMEOUT_MS,
43 assertSupportedOAuthEndpointUrl,
44} from "./oauth-helpers";
41 45
42export {46export {
43 DEFAULT_SUBJECT_LAST_SEEN_THROTTLE_MS,47 DEFAULT_SUBJECT_LAST_SEEN_THROTTLE_MS,
packages/core/sdk/src/oauth-helpers.test.ts+20−2
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
25 idTokenIdentityLabel,25 idTokenIdentityLabel,
26 isPermanentTokenRejection,26 isPermanentTokenRejection,
27 isUnusableSuccessTokenResponse,27 isUnusableSuccessTokenResponse,
28 optionalScopesFromAuthorizationUrl,
28 refreshAccessToken,29 refreshAccessToken,
29 shouldRefreshToken,30 shouldRefreshToken,
30} from "./oauth-helpers";31} from "./oauth-helpers";
@@ -193,16 +194,33 @@ describe("PKCE", () => { · 162 unmodified lines
193// buildAuthorizationUrl194// buildAuthorizationUrl
194// ---------------------------------------------------------------------------195// ---------------------------------------------------------------------------
195 196
196describe("providerAuthorizeExtras (Google offline/consent quirk)", () => {197describe("providerAuthorizeExtras (provider authorization quirks)", () => {
197 it("adds access_type=offline + prompt=consent for the Google authorize host", () => {198 it("adds access_type=offline + prompt=consent for the Google authorize host", () => {
198 expect(providerAuthorizeExtras("https://accounts.google.com/o/oauth2/v2/auth")).toEqual({199 expect(providerAuthorizeExtras("https://accounts.google.com/o/oauth2/v2/auth")).toEqual({
199 access_type: "offline",200 access_type: "offline",
200 prompt: "consent",201 prompt: "consent",
201 });202 });
202 });203 });
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", () => {
204 expect(providerAuthorizeExtras("https://accounts.spotify.com/authorize")).toEqual({});221 expect(providerAuthorizeExtras("https://accounts.spotify.com/authorize")).toEqual({});
205 expect(providerAuthorizeExtras("https://oauth2.googleapis.com/token")).toEqual({});222 expect(providerAuthorizeExtras("https://oauth2.googleapis.com/token")).toEqual({});
223 expect(providerAuthorizeExtras("https://api.hubapi.com/oauth/v3/token")).toEqual({});
206 expect(providerAuthorizeExtras("not a url")).toEqual({});224 expect(providerAuthorizeExtras("not a url")).toEqual({});
207 });225 });
208});226});
packages/core/sdk/src/oauth-helpers.ts+34−1
@@ -113,6 +113,16 @@ export const OAUTH2_REFRESH_SKEW_MS = 60_000; · 112 unmodified lines
113/** Default token-endpoint timeout. */113/** Default token-endpoint timeout. */
114export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000;114export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000;
115 115
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. */
120export const HUBSPOT_OPTIONAL_SCOPES = [
121 "content",
122 "crm.objects.custom.read",
123 "crm.schemas.custom.read",
124] as const;
125
116/** RFC 8693 §2.1 token-exchange grant. */126/** RFC 8693 §2.1 token-exchange grant. */
117export const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";127export const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";
118 128
@@ -247,7 +257,12 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string · 128 unmodified lines
247 * re-consent can silently keep the old scope set. Do not add257 * re-consent can silently keep the old scope set. Do not add
248 * `include_granted_scopes=true` here: with historical grants on the same Google258 * `include_granted_scopes=true` here: with historical grants on the same Google
249 * consent app, Google folds those unrelated scopes into the new consent flow and259 * 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. */
251export const providerAuthorizeExtras = (266export const providerAuthorizeExtras = (
252 authorizationUrl: string,267 authorizationUrl: string,
253): Readonly<Record<string, string>> => {268): Readonly<Record<string, string>> => {
@@ -257,12 +272,30 @@ export const providerAuthorizeExtras = ( · 3 unmodified lines
257 if (host === "accounts.google.com") {272 if (host === "accounts.google.com") {
258 return { access_type: "offline", prompt: "consent" };273 return { access_type: "offline", prompt: "consent" };
259 }274 }
275 if (host === "app.hubspot.com") {
276 return { optional_scope: HUBSPOT_OPTIONAL_SCOPES.join(" ") };
277 }
260 } catch {278 } catch {
261 // Unparseable authorization URL — let buildAuthorizationUrl surface the error.279 // Unparseable authorization URL — let buildAuthorizationUrl surface the error.
262 }280 }
263 return {};281 return {};
264};282};
265 283
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. */
288export 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
266// ---------------------------------------------------------------------------299// ---------------------------------------------------------------------------
267// Regional token-endpoint rebind300// Regional token-endpoint rebind
268//301//
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
37 * scopes (the MCP/no-template-scopes case). */37 * scopes (the MCP/no-template-scopes case). */
38const makeScopePluginWithId = <const TId extends string>(38const makeScopePluginWithId = <const TId extends string>(
39 id: TId,39 id: TId,
40 config: { readonly scopes: readonly string[] | null },40 config: {
41 readonly scopes: readonly string[] | null;
42 readonly authorizationUrl?: string;
43 },
41 options: { readonly discoversScopes?: boolean; readonly discoveryUrl?: string } = {},44 options: { readonly discoversScopes?: boolean; readonly discoveryUrl?: string } = {},
42) =>45) =>
43 definePlugin(() => ({46 definePlugin(() => ({
@@ -49,7 +52,10 @@ const makeScopePluginWithId = <const TId extends string>( · 5 unmodified lines
49 }),52 }),
50 invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }),53 invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }),
51 describeAuthMethods: (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {54 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;
53 const scopes = cfg?.scopes;59 const scopes = cfg?.scopes;
54 if (scopes == null) {60 if (scopes == null) {
55 // No declared oauth scopes. Server-targeting methods (MCP) expose a61 // No declared oauth scopes. Server-targeting methods (MCP) expose a
@@ -73,7 +79,12 @@ const makeScopePluginWithId = <const TId extends string>( · 17 unmodified lines
73 label: "OAuth2",79 label: "OAuth2",
74 kind: "oauth",80 kind: "oauth",
75 template: String(TEMPLATE),81 template: String(TEMPLATE),
76 oauth: { scopes },82 oauth: {
83 scopes,
84 ...(cfg?.authorizationUrl === undefined
85 ? {}
86 : { authorizationUrl: cfg.authorizationUrl }),
87 },
77 },88 },
78 ];89 ];
79 },90 },
@@ -82,13 +93,20 @@ const makeScopePluginWithId = <const TId extends string>( · 2 unmodified lines
82 ctx.core.integrations.register({93 ctx.core.integrations.register({
83 slug: INTEG,94 slug: INTEG,
84 description: "Acme",95 description: "Acme",
85 config: { scopes: config.scopes },96 config: {
97 scopes: config.scopes,
98 ...(config.authorizationUrl === undefined
99 ? {}
100 : { authorizationUrl: config.authorizationUrl }),
101 },
86 }),102 }),
87 }),103 }),
88 }))();104 }))();
89 105
90const makeScopePlugin = (config: { readonly scopes: readonly string[] | null }) =>106const makeScopePlugin = (config: {
91 makeScopePluginWithId("acme", config);107 readonly scopes: readonly string[] | null;
108 readonly authorizationUrl?: string;
109}) => makeScopePluginWithId("acme", config);
92 110
93const makeMcpScopePlugin = (config: { readonly scopes: readonly string[] | null }) =>111const makeMcpScopePlugin = (config: { readonly scopes: readonly string[] | null }) =>
94 makeScopePluginWithId("mcp", config, { discoversScopes: true });112 makeScopePluginWithId("mcp", config, { discoversScopes: true });
@@ -226,6 +244,57 @@ describe("oauth.start integration-driven scopes", () => { · 131 unmodified lines
226 ),244 ),
227 );245 );
228 246
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
229 it.effect("filters stale declared scopes against authorization-server metadata", () =>298 it.effect("filters stale declared scopes against authorization-server metadata", () =>
230 Effect.scoped(299 Effect.scoped(
231 Effect.gen(function* () {300 Effect.gen(function* () {
packages/core/sdk/src/oauth-service.ts+26−4
@@ -199,7 +199,14 @@ const startErrorFromEnterpriseManaged = (cause: EnterpriseManagedMintError): OAu · 198 unmodified lines
199 * 8707 resource: a user may clear the client's resource (Entra v2 rejects199 * 8707 resource: a user may clear the client's resource (Entra v2 rejects
200 * the parameter, #1789) without losing scope discovery. */200 * the parameter, #1789) without losing scope discovery. */
201export type OAuthScopePolicy =201export 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 }
203 | { readonly kind: "discover"; readonly discoveryUrl: string };210 | { readonly kind: "discover"; readonly discoveryUrl: string };
204 211
205/** Everything the OAuth service needs from the executor: fuma access for the212/** 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
2006 : scopePolicy.kind === "discover"2013 : scopePolicy.kind === "discover"
2007 ? requestedScopes2014 ? requestedScopes
2008 : yield* filterAuthorizationCodeScopes(client, requestedScopes);2015 : 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);
2009 const completeAuthorizationScopes = dedupeScopes([2024 const completeAuthorizationScopes = dedupeScopes([
2010 ...authorizationRequestedScopes,2025 ...authorizationRequestedScopes.filter((scope) => !workspaceOptionalScopeSet.has(scope)),
2011 ...(firstParty?.additionalAuthorizationScopes ?? []),2026 ...(firstParty?.additionalAuthorizationScopes ?? []),
2012 ...additionalAuthorizationLifecycleScopes(client),2027 ...additionalAuthorizationLifecycleScopes(client),
2013 ]);2028 ]);
2029 const completeRequestedScopes = dedupeScopes([
2030 ...completeAuthorizationScopes,
2031 ...workspaceOptionalScopes,
2032 ]);
2014 2033
2015 // authorization_code: persist a session + build the authorize URL.2034 // authorization_code: persist a session + build the authorize URL.
2016 const verifier = createPkceCodeVerifier();2035 const verifier = createPkceCodeVerifier();
@@ -2072,7 +2091,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines
2072 payload: {2091 payload: {
2073 owner: input.owner,2092 owner: input.owner,
2074 clientOwner: input.clientOwner,2093 clientOwner: input.clientOwner,
2075 requestedScopes: completeAuthorizationScopes,2094 requestedScopes: completeRequestedScopes,
2076 },2095 },
2077 expires_at: expiresAt,2096 expires_at: expiresAt,
2078 created_at: now,2097 created_at: now,
@@ -2094,7 +2113,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 15 unmodified lines
2094 // without these Google returns no refresh token and won't re-consent2113 // without these Google returns no refresh token and won't re-consent
2095 // to widen scopes on reconnect.2114 // to widen scopes on reconnect.
2096 extraParams: {2115 extraParams: {
2097 ...providerAuthorizeExtras(client.authorizationUrl),2116 ...providerExtras,
2117 ...(workspaceOptionalScopes.length > 0
2118 ? { optional_scope: workspaceOptionalScopes.join(" ") }
2119 : {}),
2098 ...(firstParty?.authorizationExtraParams ?? {}),2120 ...(firstParty?.authorizationExtraParams ?? {}),
2099 },2121 },
2100 endpointUrlPolicy: deps.endpointUrlPolicy,2122 endpointUrlPolicy: deps.endpointUrlPolicy,