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
10 changes: 9 additions & 1 deletion packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ import {
exchangeClientCredentials,
isPermanentTokenRejection,
isUnusableSuccessTokenResponse,
optionalScopesFromAuthorizationUrl,
shouldRefreshToken,
type OAuth2TokenResponse,
type OAuthEndpointUrlPolicy,
Expand Down Expand Up @@ -6119,7 +6120,14 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
if (oauth?.scopes === undefined && oauth?.discoveryUrl !== undefined) {
return { kind: "discover", discoveryUrl: oauth.discoveryUrl };
}
return { kind: "scopes", scopes: oauth?.scopes ?? [] };
return {
kind: "scopes",
scopes: oauth?.scopes ?? [],
optionalScopes:
oauth?.authorizationUrl === undefined
? []
: optionalScopesFromAuthorizationUrl(oauth.authorizationUrl),
};
}),
),
httpClientLayer: config.httpClientLayer,
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
22 changes: 20 additions & 2 deletions packages/core/sdk/src/oauth-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
idTokenIdentityLabel,
isPermanentTokenRejection,
isUnusableSuccessTokenResponse,
optionalScopesFromAuthorizationUrl,
refreshAccessToken,
shouldRefreshToken,
} from "./oauth-helpers";
Expand Down Expand Up @@ -188,16 +189,33 @@ 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("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({});
expect(providerAuthorizeExtras("https://api.hubapi.com/oauth/v3/token")).toEqual({});
expect(providerAuthorizeExtras("not a url")).toEqual({});
});
});
Expand Down
35 changes: 34 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,12 +272,30 @@ 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.
}
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
//
Expand Down
81 changes: 75 additions & 6 deletions packages/core/sdk/src/oauth-scope-union.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ const DECLARED_SCOPES = ["calendar", "gmail", "drive", "sheets"] as const;
* scopes (the MCP/no-template-scopes case). */
const makeScopePluginWithId = <const TId extends string>(
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(() => ({
Expand All @@ -49,7 +52,10 @@ const makeScopePluginWithId = <const TId extends string>(
}),
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
Expand All @@ -73,7 +79,12 @@ const makeScopePluginWithId = <const TId extends string>(
label: "OAuth2",
kind: "oauth",
template: String(TEMPLATE),
oauth: { scopes },
oauth: {
scopes,
...(cfg?.authorizationUrl === undefined
? {}
: { authorizationUrl: cfg.authorizationUrl }),
},
},
];
},
Expand All @@ -82,13 +93,20 @@ const makeScopePluginWithId = <const TId extends string>(
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 });
Expand Down Expand Up @@ -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* () {
Expand Down
30 changes: 26 additions & 4 deletions packages/core/sdk/src/oauth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading