Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions apps/cloud/src/engine/first-party-oauth-clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/core/sdk/src/host-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions packages/core/sdk/src/oauth-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
});
});
Expand Down
20 changes: 19 additions & 1 deletion packages/core/sdk/src/oauth-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<Record<string, string>> => {
Expand All @@ -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.
}
Expand Down
Loading