From 51c2a473e34acce40a0af2c20a883fc670c40b62 Mon Sep 17 00:00:00 2001 From: Lloyd Vickery Date: Mon, 31 Aug 2026 15:08:56 +1200 Subject: [PATCH] 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. }