From 51c2a473e34acce40a0af2c20a883fc670c40b62 Mon Sep 17 00:00:00 2001 From: Lloyd Vickery Date: Mon, 31 Aug 2026 15:08:56 +1200 Subject: [PATCH 1/3] Fix HubSpot optional scopes for workspace OAuth --- .../src/engine/first-party-oauth-clients.ts | 7 +------ packages/core/sdk/src/host-internal.ts | 6 +++++- packages/core/sdk/src/oauth-helpers.test.ts | 12 +++++++++-- packages/core/sdk/src/oauth-helpers.ts | 20 ++++++++++++++++++- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/cloud/src/engine/first-party-oauth-clients.ts b/apps/cloud/src/engine/first-party-oauth-clients.ts index 8b8ba02b73..a08f0cf9dd 100644 --- a/apps/cloud/src/engine/first-party-oauth-clients.ts +++ b/apps/cloud/src/engine/first-party-oauth-clients.ts @@ -6,6 +6,7 @@ import { } from "@executor-js/plugin-openapi/providers/microsoft"; import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk"; +import { HUBSPOT_OPTIONAL_SCOPES } from "@executor-js/sdk/host-internal"; /** Cloud secret bindings that enable host-operated OAuth clients. A provider * is absent unless both values in its pair are present. */ @@ -147,12 +148,6 @@ const HUBSPOT_REQUIRED_SCOPES = [ "timeline", ] as const; -const HUBSPOT_OPTIONAL_SCOPES = [ - "content", - "crm.objects.custom.read", - "crm.schemas.custom.read", -] as const; - const MICROSOFT_SCOPES = [ "User.Read", "Calendars.ReadWrite", diff --git a/packages/core/sdk/src/host-internal.ts b/packages/core/sdk/src/host-internal.ts index 834e0e4e81..7d3ada6029 100644 --- a/packages/core/sdk/src/host-internal.ts +++ b/packages/core/sdk/src/host-internal.ts @@ -37,7 +37,11 @@ export { type HostedHttpClientOptions, } from "./hosted-http-client"; -export { OAUTH2_DEFAULT_TIMEOUT_MS, assertSupportedOAuthEndpointUrl } from "./oauth-helpers"; +export { + HUBSPOT_OPTIONAL_SCOPES, + OAUTH2_DEFAULT_TIMEOUT_MS, + assertSupportedOAuthEndpointUrl, +} from "./oauth-helpers"; export { DEFAULT_SUBJECT_LAST_SEEN_THROTTLE_MS, diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 99e7090abe..807b6a3232 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -188,16 +188,24 @@ describe("PKCE", () => { // buildAuthorizationUrl // --------------------------------------------------------------------------- -describe("providerAuthorizeExtras (Google offline/consent quirk)", () => { +describe("providerAuthorizeExtras (provider authorization quirks)", () => { it("adds access_type=offline + prompt=consent for the Google authorize host", () => { expect(providerAuthorizeExtras("https://accounts.google.com/o/oauth2/v2/auth")).toEqual({ access_type: "offline", prompt: "consent", }); }); - it("adds nothing for non-Google hosts or an unparseable URL (token host ≠ authorize host)", () => { + + it("adds optional_scope for workspace-owned HubSpot OAuth clients", () => { + expect(providerAuthorizeExtras("https://app.hubspot.com/oauth/authorize")).toEqual({ + optional_scope: "content crm.objects.custom.read crm.schemas.custom.read", + }); + }); + + it("adds nothing for unrelated hosts, token hosts, or an unparseable URL", () => { expect(providerAuthorizeExtras("https://accounts.spotify.com/authorize")).toEqual({}); expect(providerAuthorizeExtras("https://oauth2.googleapis.com/token")).toEqual({}); + expect(providerAuthorizeExtras("https://api.hubapi.com/oauth/v3/token")).toEqual({}); expect(providerAuthorizeExtras("not a url")).toEqual({}); }); }); diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index f3cb2baa9a..f4f4aadf31 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -113,6 +113,16 @@ export const OAUTH2_REFRESH_SKEW_MS = 60_000; /** Default token-endpoint timeout. */ export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000; +/** HubSpot scopes that the registered app may grant but must receive through + * HubSpot's non-standard `optional_scope` authorize parameter. Keeping these + * out of the RFC `scope` parameter lets accounts without the corresponding + * product features complete consent while still granting them when present. */ +export const HUBSPOT_OPTIONAL_SCOPES = [ + "content", + "crm.objects.custom.read", + "crm.schemas.custom.read", +] as const; + /** RFC 8693 §2.1 token-exchange grant. */ export const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; @@ -247,7 +257,12 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string * re-consent can silently keep the old scope set. Do not add * `include_granted_scopes=true` here: with historical grants on the same Google * consent app, Google folds those unrelated scopes into the new consent flow and - * can fail inside accounts.google.com before returning to our callback. */ + * can fail inside accounts.google.com before returning to our callback. + * + * HubSpot: app scopes marked optional are ignored when they are omitted from + * the provider-specific `optional_scope` parameter. The OpenAPI auth template + * can only declare RFC scopes, so this host-level quirk must apply to both + * first-party and workspace-owned HubSpot OAuth clients. */ export const providerAuthorizeExtras = ( authorizationUrl: string, ): Readonly> => { @@ -257,6 +272,9 @@ export const providerAuthorizeExtras = ( if (host === "accounts.google.com") { return { access_type: "offline", prompt: "consent" }; } + if (host === "app.hubspot.com") { + return { optional_scope: HUBSPOT_OPTIONAL_SCOPES.join(" ") }; + } } catch { // Unparseable authorization URL — let buildAuthorizationUrl surface the error. } From db75c0c102c98a21d0a065686530f331fb0b4cf9 Mon Sep 17 00:00:00 2001 From: Lloyd Vickery Date: Wed, 2 Sep 2026 13:58:34 +1000 Subject: [PATCH 2/3] chore: rerun flaky cloud E2E From a637902f241d69df08d9864fca8ded217f92e774 Mon Sep 17 00:00:00 2001 From: Lloyd Vickery Date: Wed, 2 Sep 2026 14:27:10 +1000 Subject: [PATCH 3/3] Honor integration-declared HubSpot optional scopes --- packages/core/sdk/src/executor.ts | 10 ++- packages/core/sdk/src/oauth-helpers.test.ts | 10 +++ packages/core/sdk/src/oauth-helpers.ts | 15 ++++ .../core/sdk/src/oauth-scope-union.test.ts | 81 +++++++++++++++++-- packages/core/sdk/src/oauth-service.ts | 30 ++++++- 5 files changed, 135 insertions(+), 11 deletions(-) diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index aba6b674c2..34864a55e6 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -189,6 +189,7 @@ import { exchangeClientCredentials, isPermanentTokenRejection, isUnusableSuccessTokenResponse, + optionalScopesFromAuthorizationUrl, shouldRefreshToken, type OAuth2TokenResponse, type OAuthEndpointUrlPolicy, @@ -6119,7 +6120,14 @@ export const createExecutor = { }); }); + it("reads integration-declared optional_scope values from an authorization URL", () => { + expect( + optionalScopesFromAuthorizationUrl( + "https://app.hubspot.com/oauth/authorize?optional_scope=crm.objects.contacts.read+crm.objects.contacts.write+crm.objects.contacts.read", + ), + ).toEqual(["crm.objects.contacts.read", "crm.objects.contacts.write"]); + expect(optionalScopesFromAuthorizationUrl("not a url")).toEqual([]); + }); + it("adds nothing for unrelated hosts, token hosts, or an unparseable URL", () => { expect(providerAuthorizeExtras("https://accounts.spotify.com/authorize")).toEqual({}); expect(providerAuthorizeExtras("https://oauth2.googleapis.com/token")).toEqual({}); diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index f4f4aadf31..abcbebe2b5 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -281,6 +281,21 @@ export const providerAuthorizeExtras = ( return {}; }; +/** Provider-specific scopes embedded in an integration's authorization + * endpoint. HubSpot models app-optional permissions with the non-standard + * `optional_scope` query parameter, so they are part of the integration's + * request contract rather than the registered OAuth app identity. */ +export const optionalScopesFromAuthorizationUrl = (authorizationUrl: string): readonly string[] => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input -> no optional scopes + try { + const value = new URL(authorizationUrl).searchParams.get("optional_scope"); + if (value == null) return []; + return [...new Set(value.split(/\s+/).filter(Boolean))]; + } catch { + return []; + } +}; + // --------------------------------------------------------------------------- // Regional token-endpoint rebind // diff --git a/packages/core/sdk/src/oauth-scope-union.test.ts b/packages/core/sdk/src/oauth-scope-union.test.ts index 6e7aa41669..655f62dd56 100644 --- a/packages/core/sdk/src/oauth-scope-union.test.ts +++ b/packages/core/sdk/src/oauth-scope-union.test.ts @@ -37,7 +37,10 @@ const DECLARED_SCOPES = ["calendar", "gmail", "drive", "sheets"] as const; * scopes (the MCP/no-template-scopes case). */ const makeScopePluginWithId = ( id: TId, - config: { readonly scopes: readonly string[] | null }, + config: { + readonly scopes: readonly string[] | null; + readonly authorizationUrl?: string; + }, options: { readonly discoversScopes?: boolean; readonly discoveryUrl?: string } = {}, ) => definePlugin(() => ({ @@ -49,7 +52,10 @@ const makeScopePluginWithId = ( }), invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), describeAuthMethods: (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { - const cfg = record.config as { readonly scopes?: readonly string[] | null } | null; + const cfg = record.config as { + readonly scopes?: readonly string[] | null; + readonly authorizationUrl?: string; + } | null; const scopes = cfg?.scopes; if (scopes == null) { // No declared oauth scopes. Server-targeting methods (MCP) expose a @@ -73,7 +79,12 @@ const makeScopePluginWithId = ( label: "OAuth2", kind: "oauth", template: String(TEMPLATE), - oauth: { scopes }, + oauth: { + scopes, + ...(cfg?.authorizationUrl === undefined + ? {} + : { authorizationUrl: cfg.authorizationUrl }), + }, }, ]; }, @@ -82,13 +93,20 @@ const makeScopePluginWithId = ( ctx.core.integrations.register({ slug: INTEG, description: "Acme", - config: { scopes: config.scopes }, + config: { + scopes: config.scopes, + ...(config.authorizationUrl === undefined + ? {} + : { authorizationUrl: config.authorizationUrl }), + }, }), }), }))(); -const makeScopePlugin = (config: { readonly scopes: readonly string[] | null }) => - makeScopePluginWithId("acme", config); +const makeScopePlugin = (config: { + readonly scopes: readonly string[] | null; + readonly authorizationUrl?: string; +}) => makeScopePluginWithId("acme", config); const makeMcpScopePlugin = (config: { readonly scopes: readonly string[] | null }) => makeScopePluginWithId("mcp", config, { discoversScopes: true }); @@ -223,6 +241,57 @@ describe("oauth.start integration-driven scopes", () => { ), ); + it.effect("moves integration-declared optional scopes out of scope into optional_scope", () => + Effect.scoped( + Effect.gen(function* () { + const declared = [ + "oauth", + "crm.objects.contacts.read", + "crm.objects.companies.read", + ] as const; + const optional = [ + "crm.objects.contacts.read", + "crm.objects.companies.read", + "content", + ] as const; + const server = yield* serveOAuthTestServer({ scopes: [...declared] }); + const plugins = [ + memoryCredentialsPlugin(), + makeScopePlugin({ + scopes: declared, + authorizationUrl: `${server.authorizationEndpoint}?optional_scope=${optional.join("+")}`, + }), + ] as const; + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + const url = new URL(started.authorizationUrl); + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["oauth"]); + expect(url.searchParams.get("optional_scope")?.split(/\s+/)).toEqual(optional); + }), + ), + ); + it.effect("filters stale declared scopes against authorization-server metadata", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 5d7b0ef8a0..c81df7264b 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -174,7 +174,14 @@ const startErrorFromEnterpriseManaged = (cause: EnterpriseManagedMintError): OAu * 8707 resource: a user may clear the client's resource (Entra v2 rejects * the parameter, #1789) without losing scope discovery. */ export type OAuthScopePolicy = - | { readonly kind: "scopes"; readonly scopes: readonly string[] } + | { + readonly kind: "scopes"; + readonly scopes: readonly string[]; + /** Provider-specific scopes declared on the integration's authorization + * endpoint (HubSpot `optional_scope`). These must not also be sent in + * the RFC `scope` parameter. */ + readonly optionalScopes?: readonly string[]; + } | { readonly kind: "discover"; readonly discoveryUrl: string }; /** Everything the OAuth service needs from the executor: fuma access for the @@ -1720,10 +1727,22 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { : scopePolicy.kind === "discover" ? requestedScopes : yield* filterAuthorizationCodeScopes(client, requestedScopes); + const providerExtras = providerAuthorizeExtras(client.authorizationUrl); + const workspaceOptionalScopes = firstPartyFlow + ? [] + : dedupeScopes([ + ...(providerExtras.optional_scope ?? "").split(/\s+/).filter(Boolean), + ...(scopePolicy.kind === "scopes" ? (scopePolicy.optionalScopes ?? []) : []), + ]); + const workspaceOptionalScopeSet = new Set(workspaceOptionalScopes); const completeAuthorizationScopes = dedupeScopes([ - ...authorizationRequestedScopes, + ...authorizationRequestedScopes.filter((scope) => !workspaceOptionalScopeSet.has(scope)), ...(firstParty?.additionalAuthorizationScopes ?? []), ]); + const completeRequestedScopes = dedupeScopes([ + ...completeAuthorizationScopes, + ...workspaceOptionalScopes, + ]); // authorization_code: persist a session + build the authorize URL. const verifier = createPkceCodeVerifier(); @@ -1785,7 +1804,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { payload: { owner: input.owner, clientOwner: input.clientOwner, - requestedScopes: completeAuthorizationScopes, + requestedScopes: completeRequestedScopes, }, expires_at: expiresAt, created_at: now, @@ -1807,7 +1826,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // without these Google returns no refresh token and won't re-consent // to widen scopes on reconnect. extraParams: { - ...providerAuthorizeExtras(client.authorizationUrl), + ...providerExtras, + ...(workspaceOptionalScopes.length > 0 + ? { optional_scope: workspaceOptionalScopes.join(" ") } + : {}), ...(firstParty?.authorizationExtraParams ?? {}), }, endpointUrlPolicy: deps.endpointUrlPolicy,