* 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 ---------
Unified · Split
| @@ -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. | |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | ||
| 6 | 6 | } from "@executor-js/plugin-openapi/providers/microsoft"; |
| 7 | 7 | import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; |
| 8 | 8 | import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk"; |
| 9 | +import { HUBSPOT_OPTIONAL_SCOPES } from "@executor-js/sdk/host-internal"; | |
| 9 | 10 | |
| 10 | 11 | import { makeGoogleOAuthListing } from "../analytics/google-oauth-listing"; |
| 11 | 12 | import { POSTHOG_INGEST_HOST } from "../edge/passthrough"; |
| @@ -152,12 +153,6 @@ const HUBSPOT_REQUIRED_SCOPES = [ · 140 unmodified lines | ||
| 152 | 153 | "timeline", |
| 153 | 154 | ] as const; |
| 154 | 155 | |
| 155 | −const HUBSPOT_OPTIONAL_SCOPES = [ | |
| 156 | − "content", | |
| 157 | − "crm.objects.custom.read", | |
| 158 | − "crm.schemas.custom.read", | |
| 159 | −] as const; | |
| 160 | − | |
| 161 | 156 | const MICROSOFT_SCOPES = [ |
| 162 | 157 | "User.Read", |
| 163 | 158 | "Calendars.ReadWrite", |
| @@ -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 | +); | |
| @@ -208,6 +208,7 @@ import { · 207 unmodified lines | ||
| 208 | 208 | exchangeClientCredentials, |
| 209 | 209 | isPermanentTokenRejection, |
| 210 | 210 | isUnusableSuccessTokenResponse, |
| 211 | + optionalScopesFromAuthorizationUrl, | |
| 211 | 212 | shouldRefreshToken, |
| 212 | 213 | type OAuth2TokenResponse, |
| 213 | 214 | type OAuthEndpointUrlPolicy, |
| @@ -6711,7 +6712,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 6497 unmodified lines | ||
| 6711 | 6712 | discoveryUrl: oauth.discoveryUrl, |
| 6712 | 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 | 6724 | httpClientLayer: config.httpClientLayer, |
| 6717 | 6725 | fetch: config.fetch, |
| @@ -37,7 +37,11 @@ export { · 36 unmodified lines | ||
| 37 | 37 | type HostedHttpClientOptions, |
| 38 | 38 | } from "./hosted-http-client"; |
| 39 | 39 | |
| 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"; | |
| 41 | 45 | |
| 42 | 46 | export { |
| 43 | 47 | DEFAULT_SUBJECT_LAST_SEEN_THROTTLE_MS, |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | ||
| 25 | 25 | idTokenIdentityLabel, |
| 26 | 26 | isPermanentTokenRejection, |
| 27 | 27 | isUnusableSuccessTokenResponse, |
| 28 | + optionalScopesFromAuthorizationUrl, | |
| 28 | 29 | refreshAccessToken, |
| 29 | 30 | shouldRefreshToken, |
| 30 | 31 | } from "./oauth-helpers"; |
| @@ -193,16 +194,33 @@ describe("PKCE", () => { · 162 unmodified lines | ||
| 193 | 194 | // buildAuthorizationUrl |
| 194 | 195 | // --------------------------------------------------------------------------- |
| 195 | 196 | |
| 196 | −describe("providerAuthorizeExtras (Google offline/consent quirk)", () => { | |
| 197 | +describe("providerAuthorizeExtras (provider authorization quirks)", () => { | |
| 197 | 198 | it("adds access_type=offline + prompt=consent for the Google authorize host", () => { |
| 198 | 199 | expect(providerAuthorizeExtras("https://accounts.google.com/o/oauth2/v2/auth")).toEqual({ |
| 199 | 200 | access_type: "offline", |
| 200 | 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 | 221 | expect(providerAuthorizeExtras("https://accounts.spotify.com/authorize")).toEqual({}); |
| 205 | 222 | expect(providerAuthorizeExtras("https://oauth2.googleapis.com/token")).toEqual({}); |
| 223 | + expect(providerAuthorizeExtras("https://api.hubapi.com/oauth/v3/token")).toEqual({}); | |
| 206 | 224 | expect(providerAuthorizeExtras("not a url")).toEqual({}); |
| 207 | 225 | }); |
| 208 | 226 | }); |
| @@ -113,6 +113,16 @@ export const OAUTH2_REFRESH_SKEW_MS = 60_000; · 112 unmodified lines | ||
| 113 | 113 | /** Default token-endpoint timeout. */ |
| 114 | 114 | export 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. */ | |
| 120 | +export const HUBSPOT_OPTIONAL_SCOPES = [ | |
| 121 | + "content", | |
| 122 | + "crm.objects.custom.read", | |
| 123 | + "crm.schemas.custom.read", | |
| 124 | +] as const; | |
| 125 | + | |
| 116 | 126 | /** RFC 8693 §2.1 token-exchange grant. */ |
| 117 | 127 | export 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 | 257 | * re-consent can silently keep the old scope set. Do not add |
| 248 | 258 | * `include_granted_scopes=true` here: with historical grants on the same Google |
| 249 | 259 | * 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. */ | |
| 251 | 266 | export const providerAuthorizeExtras = ( |
| 252 | 267 | authorizationUrl: string, |
| 253 | 268 | ): Readonly<Record<string, string>> => { |
| @@ -257,12 +272,30 @@ export const providerAuthorizeExtras = ( · 3 unmodified lines | ||
| 257 | 272 | if (host === "accounts.google.com") { |
| 258 | 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 | 278 | } catch { |
| 261 | 279 | // Unparseable authorization URL — let buildAuthorizationUrl surface the error. |
| 262 | 280 | } |
| 263 | 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. */ | |
| 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 | + | |
| 266 | 299 | // --------------------------------------------------------------------------- |
| 267 | 300 | // Regional token-endpoint rebind |
| 268 | 301 | // |
| @@ -37,7 +37,10 @@ const DECLARED_SCOPES = ["calendar", "gmail", "drive", "sheets"] as const; · 36 unmodified lines | ||
| 37 | 37 | * scopes (the MCP/no-template-scopes case). */ |
| 38 | 38 | const makeScopePluginWithId = <const TId extends string>( |
| 39 | 39 | id: TId, |
| 40 | − config: { readonly scopes: readonly string[] | null }, | |
| 40 | + config: { | |
| 41 | + readonly scopes: readonly string[] | null; | |
| 42 | + readonly authorizationUrl?: string; | |
| 43 | + }, | |
| 41 | 44 | options: { readonly discoversScopes?: boolean; readonly discoveryUrl?: string } = {}, |
| 42 | 45 | ) => |
| 43 | 46 | definePlugin(() => ({ |
| @@ -49,7 +52,10 @@ const makeScopePluginWithId = <const TId extends string>( · 5 unmodified lines | ||
| 49 | 52 | }), |
| 50 | 53 | invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), |
| 51 | 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 | 59 | const scopes = cfg?.scopes; |
| 54 | 60 | if (scopes == null) { |
| 55 | 61 | // No declared oauth scopes. Server-targeting methods (MCP) expose a |
| @@ -73,7 +79,12 @@ const makeScopePluginWithId = <const TId extends string>( · 17 unmodified lines | ||
| 73 | 79 | label: "OAuth2", |
| 74 | 80 | kind: "oauth", |
| 75 | 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 | 93 | ctx.core.integrations.register({ |
| 83 | 94 | slug: INTEG, |
| 84 | 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 | |
| 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); | |
| 92 | 110 | |
| 93 | 111 | const makeMcpScopePlugin = (config: { readonly scopes: readonly string[] | null }) => |
| 94 | 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 | 298 | it.effect("filters stale declared scopes against authorization-server metadata", () => |
| 230 | 299 | Effect.scoped( |
| 231 | 300 | Effect.gen(function* () { |
| @@ -199,7 +199,14 @@ const startErrorFromEnterpriseManaged = (cause: EnterpriseManagedMintError): OAu · 198 unmodified lines | ||
| 199 | 199 | * 8707 resource: a user may clear the client's resource (Entra v2 rejects |
| 200 | 200 | * the parameter, #1789) without losing scope discovery. */ |
| 201 | 201 | 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 | + } | |
| 203 | 210 | | { readonly kind: "discover"; readonly discoveryUrl: string }; |
| 204 | 211 | |
| 205 | 212 | /** 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 | 2013 | : scopePolicy.kind === "discover" |
| 2007 | 2014 | ? requestedScopes |
| 2008 | 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 | 2024 | const completeAuthorizationScopes = dedupeScopes([ |
| 2010 | − ...authorizationRequestedScopes, | |
| 2025 | + ...authorizationRequestedScopes.filter((scope) => !workspaceOptionalScopeSet.has(scope)), | |
| 2011 | 2026 | ...(firstParty?.additionalAuthorizationScopes ?? []), |
| 2012 | 2027 | ...additionalAuthorizationLifecycleScopes(client), |
| 2013 | 2028 | ]); |
| 2029 | + const completeRequestedScopes = dedupeScopes([ | |
| 2030 | + ...completeAuthorizationScopes, | |
| 2031 | + ...workspaceOptionalScopes, | |
| 2032 | + ]); | |
| 2014 | 2033 | |
| 2015 | 2034 | // authorization_code: persist a session + build the authorize URL. |
| 2016 | 2035 | const verifier = createPkceCodeVerifier(); |
| @@ -2072,7 +2091,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines | ||
| 2072 | 2091 | payload: { |
| 2073 | 2092 | owner: input.owner, |
| 2074 | 2093 | clientOwner: input.clientOwner, |
| 2075 | − requestedScopes: completeAuthorizationScopes, | |
| 2094 | + requestedScopes: completeRequestedScopes, | |
| 2076 | 2095 | }, |
| 2077 | 2096 | expires_at: expiresAt, |
| 2078 | 2097 | created_at: now, |
| @@ -2094,7 +2113,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 15 unmodified lines | ||
| 2094 | 2113 | // without these Google returns no refresh token and won't re-consent |
| 2095 | 2114 | // to widen scopes on reconnect. |
| 2096 | 2115 | extraParams: { |
| 2097 | − ...providerAuthorizeExtras(client.authorizationUrl), | |
| 2116 | + ...providerExtras, | |
| 2117 | + ...(workspaceOptionalScopes.length > 0 | |
| 2118 | + ? { optional_scope: workspaceOptionalScopes.join(" ") } | |
| 2119 | + : {}), | |
| 2098 | 2120 | ...(firstParty?.authorizationExtraParams ?? {}), |
| 2099 | 2121 | }, |
| 2100 | 2122 | endpointUrlPolicy: deps.endpointUrlPolicy, |