Skip to content

Commit 65d939e

Browse files
LloydVickeryASILloyd VickeryRhysSullivan
authored
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 --------- Co-authored-by: Lloyd Vickery <lvickery@Lloyds-MacBook-Pro.local> Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
1 parent 5ecb881 commit 65d939e

9 files changed

Lines changed: 347 additions & 21 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
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 = [
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",
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ import {
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
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‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ export {
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‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
idTokenIdentityLabel,
2626
isPermanentTokenRejection,
2727
isUnusableSuccessTokenResponse,
28+
optionalScopesFromAuthorizationUrl,
2829
refreshAccessToken,
2930
shouldRefreshToken,
3031
} from "./oauth-helpers";
@@ -193,16 +194,33 @@ describe("PKCE", () => {
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‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,16 @@ export const OAUTH2_REFRESH_SKEW_MS = 60_000;
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
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 = (
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
//

0 commit comments

Comments
 (0)