From efa9d4a4e22460617615335fa765c73e15af5603 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:22:15 -0700 Subject: [PATCH] Add registered first-party OAuth providers --- apps/cloud/src/engine/execution-stack.ts | 97 +----- .../engine/first-party-oauth-clients.test.ts | 97 ++++++ .../src/engine/first-party-oauth-clients.ts | 310 ++++++++++++++++++ apps/cloud/src/env-augment.d.ts | 20 ++ packages/core/sdk/src/executor.ts | 12 +- packages/core/sdk/src/oauth-client.ts | 19 ++ .../core/sdk/src/oauth-first-party.test.ts | 36 ++ packages/core/sdk/src/oauth-helpers.test.ts | 83 ++++- packages/core/sdk/src/oauth-helpers.ts | 118 ++++++- packages/core/sdk/src/oauth-service.ts | 26 +- packages/plugins/openapi/src/sdk/presets.ts | 4 +- 11 files changed, 708 insertions(+), 114 deletions(-) create mode 100644 apps/cloud/src/engine/first-party-oauth-clients.test.ts create mode 100644 apps/cloud/src/engine/first-party-oauth-clients.ts diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index 013774bc8..743c5a21d 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -42,18 +42,13 @@ import { PluginsProvider, collectTables, } from "@executor-js/api/server"; -import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google"; -import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; -import { - IntegrationSlug, - type AnyPlugin, - type FirstPartyOAuthClientConfig, -} from "@executor-js/sdk"; +import { type AnyPlugin } from "@executor-js/sdk"; import executorConfig from "../../executor.config"; import { DbService } from "../db/db"; import { cloudDbProviderLayer } from "../db/fuma"; +import { firstPartyOAuthClientsFor } from "./first-party-oauth-clients"; export { makeExecutionStack } from "@executor-js/api/server"; @@ -94,92 +89,6 @@ export const CloudPluginsProvider: Layer.Layer = Layer.succeed( */ export const CLOUD_MOUNT_PREFIX = "/api" as const; -// Consumer Google launch boundary. Keep this list aligned with the scopes -// submitted for the Executor-owned production app: ordinary Workspace services -// plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script, -// BigQuery, and Cloud Resource Manager have materially different audiences or -// provider requirements and remain BYO OAuth. The same scope source builds each -// catalog auth template, preventing picker/start drift. -const GOOGLE_FIRST_PARTY_PRESET_IDS = [ - "google-calendar", - "google-meet", - "google-gmail", - "google-sheets", - "google-drive", - "google-docs", - "google-slides", - "google-forms", - "google-tasks", - "google-people", - "google-photos-library", - "google-photos-picker", - "google-search-console", -] as const; - -const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [ - ...new Set([ - ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset), - // Connections created before the full-Gmail review retain this declared - // scope on reconnect. New Gmail presets request `mail.google.com`. - "https://www.googleapis.com/auth/gmail.modify", - ]), -]; - -// Executor-owned provider apps, enabled per provider by setting BOTH env vars -// (id + secret). Each provider-side registration must list -// `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug -// travels inside OAuth `state`, so the single static callback serves every org. -// -// The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` / -// `_TOKEN_URL` overrides exist so tests and dev instances can point the app at -// an emulated provider (`@executor-js/emulate`) and run the complete flow. -// Production leaves them unset. -const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] => [ - ...(env.FIRST_PARTY_GITHUB_CLIENT_ID && env.FIRST_PARTY_GITHUB_CLIENT_SECRET - ? [ - { - name: "github", - authorizationUrl: - env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize", - tokenUrl: - env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token", - clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID, - clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET, - integrations: [IntegrationSlug.make("github_rest")], - // GitHub App user access tokens do not use classic OAuth scopes; - // their capabilities come from the app's registered permissions. - authorizationScopes: [], - }, - ] - : []), - ...(env.FIRST_PARTY_GOOGLE_CLIENT_ID && env.FIRST_PARTY_GOOGLE_CLIENT_SECRET - ? [ - { - name: "google", - authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", - tokenUrl: "https://oauth2.googleapis.com/token", - clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID, - clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET, - allowedScopes: GOOGLE_FIRST_PARTY_ALLOWED_SCOPES, - }, - ] - : []), - ...(env.FIRST_PARTY_SLACK_CLIENT_ID && env.FIRST_PARTY_SLACK_CLIENT_SECRET - ? [ - { - name: "slack", - authorizationUrl: "https://slack.com/oauth/v2_user/authorize", - tokenUrl: "https://slack.com/api/oauth.v2.user.access", - resource: "https://mcp.slack.com", - clientId: env.FIRST_PARTY_SLACK_CLIENT_ID, - clientSecret: env.FIRST_PARTY_SLACK_CLIENT_SECRET, - integrations: [IntegrationSlug.make("slack")], - allowedScopes: slackMcpUserScopes, - }, - ] - : []), -]; - export const CloudHostConfig: Layer.Layer = Layer.sync(HostConfig, () => ({ // SSRF / private-network egress guard. Config-driven, NOT a test flag: // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`); @@ -191,7 +100,7 @@ export const CloudHostConfig: Layer.Layer = Layer.sync(HostConfig, ( // WorkOS Vault is cloud's credential storage implementation detail, not a // user-selectable provider surface. exposeCredentialProviders: false, - firstPartyOAuthClients: cloudFirstPartyOAuthClients(), + firstPartyOAuthClients: firstPartyOAuthClientsFor(env), })); export const CloudCodeExecutorProvider: Layer.Layer = Layer.sync( diff --git a/apps/cloud/src/engine/first-party-oauth-clients.test.ts b/apps/cloud/src/engine/first-party-oauth-clients.test.ts new file mode 100644 index 000000000..81548106a --- /dev/null +++ b/apps/cloud/src/engine/first-party-oauth-clients.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + firstPartyOAuthClientsFor, + type FirstPartyOAuthClientEnv, +} from "./first-party-oauth-clients"; + +const completeEnv: FirstPartyOAuthClientEnv = { + FIRST_PARTY_AIRTABLE_CLIENT_ID: "airtable-id", + FIRST_PARTY_AIRTABLE_CLIENT_SECRET: "airtable-secret", + FIRST_PARTY_ATLASSIAN_CLIENT_ID: "atlassian-id", + FIRST_PARTY_ATLASSIAN_CLIENT_SECRET: "atlassian-secret", + FIRST_PARTY_BOX_CLIENT_ID: "box-id", + FIRST_PARTY_BOX_CLIENT_SECRET: "box-secret", + FIRST_PARTY_CLICKUP_CLIENT_ID: "clickup-id", + FIRST_PARTY_CLICKUP_CLIENT_SECRET: "clickup-secret", + FIRST_PARTY_FIGMA_CLIENT_ID: "figma-id", + FIRST_PARTY_FIGMA_CLIENT_SECRET: "figma-secret", + FIRST_PARTY_GITHUB_CLIENT_ID: "github-id", + FIRST_PARTY_GITHUB_CLIENT_SECRET: "github-secret", + FIRST_PARTY_GITLAB_CLIENT_ID: "gitlab-id", + FIRST_PARTY_GITLAB_CLIENT_SECRET: "gitlab-secret", + FIRST_PARTY_GOOGLE_CLIENT_ID: "google-id", + FIRST_PARTY_GOOGLE_CLIENT_SECRET: "google-secret", + FIRST_PARTY_HUBSPOT_CLIENT_ID: "hubspot-id", + FIRST_PARTY_HUBSPOT_CLIENT_SECRET: "hubspot-secret", + FIRST_PARTY_LINEAR_CLIENT_ID: "linear-id", + FIRST_PARTY_LINEAR_CLIENT_SECRET: "linear-secret", + FIRST_PARTY_MICROSOFT_CLIENT_ID: "microsoft-id", + FIRST_PARTY_MICROSOFT_CLIENT_SECRET: "microsoft-secret", + FIRST_PARTY_NOTION_CLIENT_ID: "notion-id", + FIRST_PARTY_NOTION_CLIENT_SECRET: "notion-secret", + FIRST_PARTY_SLACK_CLIENT_ID: "slack-id", + FIRST_PARTY_SLACK_CLIENT_SECRET: "slack-secret", +}; + +describe("cloud first-party OAuth clients", () => { + it("enables every registered OAuth 2 provider from complete secret pairs", () => { + const clients = firstPartyOAuthClientsFor(completeEnv); + + expect(clients.map((client) => client.name)).toEqual([ + "airtable", + "atlassian", + "box", + "clickup", + "figma", + "github", + "gitlab", + "google", + "hubspot", + "linear", + "microsoft", + "notion", + "slack", + ]); + }); + + it("fails closed when either half of a provider secret pair is absent", () => { + expect(firstPartyOAuthClientsFor({ FIRST_PARTY_AIRTABLE_CLIENT_ID: "id" })).toEqual([]); + expect(firstPartyOAuthClientsFor({ FIRST_PARTY_AIRTABLE_CLIENT_SECRET: "secret" })).toEqual([]); + }); + + it("carries provider-specific authorization and token contracts", () => { + const byName = new Map( + firstPartyOAuthClientsFor(completeEnv).map((client) => [client.name, client]), + ); + + expect(byName.get("airtable")).toMatchObject({ + tokenEndpointAuthMethod: "basic", + }); + expect(byName.get("atlassian")).toMatchObject({ + tokenRequestFormat: "json", + authorizationExtraParams: { audience: "api.atlassian.com", prompt: "consent" }, + }); + expect(byName.get("figma")).toMatchObject({ + tokenEndpointAuthMethod: "basic", + allowedScopes: expect.arrayContaining(["folder_metadata:read", "folders:read"]), + }); + expect(byName.get("hubspot")).toMatchObject({ + tokenUrl: "https://api.hubapi.com/oauth/v3/token", + authorizationExtraParams: { + optional_scope: "content crm.objects.custom.read crm.schemas.custom.read", + }, + }); + expect(byName.get("linear")).toMatchObject({ authorizationScopeSeparator: "," }); + expect(byName.get("microsoft")).toMatchObject({ + additionalAuthorizationScopes: ["offline_access"], + allowedScopes: expect.arrayContaining(["Mail.ReadWrite", "Files.ReadWrite.All"]), + }); + expect(byName.get("notion")).toMatchObject({ + authorizationScopes: [], + authorizationExtraParams: { owner: "user" }, + tokenEndpointAuthMethod: "basic", + tokenRequestFormat: "json", + }); + }); +}); diff --git a/apps/cloud/src/engine/first-party-oauth-clients.ts b/apps/cloud/src/engine/first-party-oauth-clients.ts new file mode 100644 index 000000000..206da4339 --- /dev/null +++ b/apps/cloud/src/engine/first-party-oauth-clients.ts @@ -0,0 +1,310 @@ +import { FIGMA_SUPPORTED_OAUTH_SCOPES } from "@executor-js/plugin-openapi/presets"; +import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google"; +import { + MICROSOFT_AUTHORIZATION_URL, + MICROSOFT_TOKEN_URL, +} 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"; + +/** Cloud secret bindings that enable host-operated OAuth clients. A provider + * is absent unless both values in its pair are present. */ +export interface FirstPartyOAuthClientEnv { + readonly FIRST_PARTY_AIRTABLE_CLIENT_ID?: string; + readonly FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string; + readonly FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string; + readonly FIRST_PARTY_ATLASSIAN_CLIENT_SECRET?: string; + readonly FIRST_PARTY_BOX_CLIENT_ID?: string; + readonly FIRST_PARTY_BOX_CLIENT_SECRET?: string; + readonly FIRST_PARTY_CLICKUP_CLIENT_ID?: string; + readonly FIRST_PARTY_CLICKUP_CLIENT_SECRET?: string; + readonly FIRST_PARTY_FIGMA_CLIENT_ID?: string; + readonly FIRST_PARTY_FIGMA_CLIENT_SECRET?: string; + readonly FIRST_PARTY_GITHUB_CLIENT_ID?: string; + readonly FIRST_PARTY_GITHUB_CLIENT_SECRET?: string; + readonly FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string; + readonly FIRST_PARTY_GITHUB_TOKEN_URL?: string; + readonly FIRST_PARTY_GITLAB_CLIENT_ID?: string; + readonly FIRST_PARTY_GITLAB_CLIENT_SECRET?: string; + readonly FIRST_PARTY_GOOGLE_CLIENT_ID?: string; + readonly FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string; + readonly FIRST_PARTY_HUBSPOT_CLIENT_ID?: string; + readonly FIRST_PARTY_HUBSPOT_CLIENT_SECRET?: string; + readonly FIRST_PARTY_LINEAR_CLIENT_ID?: string; + readonly FIRST_PARTY_LINEAR_CLIENT_SECRET?: string; + readonly FIRST_PARTY_MICROSOFT_CLIENT_ID?: string; + readonly FIRST_PARTY_MICROSOFT_CLIENT_SECRET?: string; + readonly FIRST_PARTY_NOTION_CLIENT_ID?: string; + readonly FIRST_PARTY_NOTION_CLIENT_SECRET?: string; + readonly FIRST_PARTY_SLACK_CLIENT_ID?: string; + readonly FIRST_PARTY_SLACK_CLIENT_SECRET?: string; +} + +const AIRTABLE_SCOPES = [ + "data.recordComments:read", + "data.recordComments:write", + "data.records:read", + "data.records:write", + "data.records:manage", + "schema.bases:read", + "schema.bases:write", + "user.email:read", + "workspacesAndBases:read", + "workspacesAndBases:write", + "workspacesAndBases.shares:manage", + "webhook:manage", +] as const; + +const ATLASSIAN_SCOPES = [ + "read:me", + "read:account", + "read:jira-work", + "manage:jira-project", + "manage:jira-configuration", + "read:jira-user", + "write:jira-work", + "manage:jira-webhook", + "read:servicedesk-request", + "manage:servicedesk-customer", + "write:servicedesk-request", + "write:confluence-content", + "read:confluence-space.summary", + "write:confluence-space", + "write:confluence-file", + "read:confluence-props", + "write:confluence-props", + "manage:confluence-configuration", + "read:confluence-content.all", + "read:confluence-content.summary", + "search:confluence", + "read:confluence-content.permission", + "read:confluence-user", + "read:confluence-groups", + "write:confluence-groups", + "offline_access", +] as const; + +const BOX_SCOPES = [ + "root_readonly", + "root_readwrite", + "sign_requests.readwrite", + "ai.readwrite", + "manage_webhook", + "manage_triggers", +] as const; + +const GITLAB_SCOPES = [ + "api", + "read_api", + "read_user", + "create_runner", + "manage_runner", + "k8s_proxy", + "mcp", + "mcp_orbit", + "read_repository", + "write_repository", + "read_registry", + "write_registry", + "read_virtual_registry", + "write_virtual_registry", + "read_observability", + "write_observability", + "ai_features", + "openid", + "profile", + "email", +] as const; + +const HUBSPOT_REQUIRED_SCOPES = [ + "oauth", + "account-info.security.read", + "cms.domains.read", + "cms.domains.write", + "crm.export", + "crm.import", + "crm.lists.read", + "crm.lists.write", + "crm.objects.companies.read", + "crm.objects.companies.write", + "crm.objects.contacts.read", + "crm.objects.contacts.write", + "crm.objects.deals.read", + "crm.objects.deals.write", + "crm.objects.marketing_events.read", + "crm.objects.marketing_events.write", + "crm.objects.owners.read", + "crm.objects.quotes.read", + "crm.objects.quotes.write", + "crm.schemas.companies.read", + "crm.schemas.companies.write", + "crm.schemas.contacts.read", + "crm.schemas.contacts.write", + "sales-email-read", + "settings.users.read", + "settings.users.write", + "tickets", + "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", + "Channel.ReadBasic.All", + "ChannelMessage.Read.All", + "ChannelMessage.Send", + "Chat.ReadWrite", + "Files.ReadWrite.All", + "Mail.ReadWrite", + "Mail.Send", + "MailboxSettings.ReadWrite", + "OnlineMeetings.ReadWrite", + "Sites.ReadWrite.All", + "Team.ReadBasic.All", + "offline_access", +] as const; + +const GOOGLE_FIRST_PARTY_PRESET_IDS = [ + "google-calendar", + "google-meet", + "google-gmail", + "google-sheets", + "google-drive", + "google-docs", + "google-slides", + "google-forms", + "google-tasks", + "google-people", + "google-photos-library", + "google-photos-picker", + "google-search-console", +] as const; + +const GOOGLE_ALLOWED_SCOPES: readonly string[] = [ + ...new Set([ + ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset), + "https://www.googleapis.com/auth/gmail.modify", + ]), +]; + +const client = ( + clientId: string | undefined, + clientSecret: string | undefined, + config: Omit, +): readonly FirstPartyOAuthClientConfig[] => + clientId && clientSecret ? [{ ...config, clientId, clientSecret }] : []; + +/** Build the enabled first-party registry from secret bindings. Provider + * protocol details and scope ceilings live here so the cloud composition root + * cannot drift from the registered production clients. */ +export const firstPartyOAuthClientsFor = ( + env: FirstPartyOAuthClientEnv, +): readonly FirstPartyOAuthClientConfig[] => [ + ...client(env.FIRST_PARTY_AIRTABLE_CLIENT_ID, env.FIRST_PARTY_AIRTABLE_CLIENT_SECRET, { + name: "airtable", + authorizationUrl: "https://airtable.com/oauth2/v1/authorize", + tokenUrl: "https://airtable.com/oauth2/v1/token", + tokenEndpointAuthMethod: "basic", + authorizationScopes: AIRTABLE_SCOPES, + allowedScopes: AIRTABLE_SCOPES, + }), + ...client(env.FIRST_PARTY_ATLASSIAN_CLIENT_ID, env.FIRST_PARTY_ATLASSIAN_CLIENT_SECRET, { + name: "atlassian", + authorizationUrl: "https://auth.atlassian.com/authorize", + tokenUrl: "https://auth.atlassian.com/oauth/token", + tokenRequestFormat: "json", + authorizationScopes: ATLASSIAN_SCOPES, + allowedScopes: ATLASSIAN_SCOPES, + authorizationExtraParams: { audience: "api.atlassian.com", prompt: "consent" }, + }), + ...client(env.FIRST_PARTY_BOX_CLIENT_ID, env.FIRST_PARTY_BOX_CLIENT_SECRET, { + name: "box", + authorizationUrl: "https://account.box.com/api/oauth2/authorize", + tokenUrl: "https://api.box.com/oauth2/token", + authorizationScopes: BOX_SCOPES, + allowedScopes: BOX_SCOPES, + }), + ...client(env.FIRST_PARTY_CLICKUP_CLIENT_ID, env.FIRST_PARTY_CLICKUP_CLIENT_SECRET, { + name: "clickup", + authorizationUrl: "https://app.clickup.com/api", + tokenUrl: "https://api.clickup.com/api/v2/oauth/token", + tokenRequestFormat: "json", + authorizationScopes: [], + }), + ...client(env.FIRST_PARTY_FIGMA_CLIENT_ID, env.FIRST_PARTY_FIGMA_CLIENT_SECRET, { + name: "figma", + authorizationUrl: "https://www.figma.com/oauth", + tokenUrl: "https://api.figma.com/v1/oauth/token", + tokenEndpointAuthMethod: "basic", + integrations: [IntegrationSlug.make("figma_api")], + authorizationScopes: FIGMA_SUPPORTED_OAUTH_SCOPES, + allowedScopes: FIGMA_SUPPORTED_OAUTH_SCOPES, + }), + ...client(env.FIRST_PARTY_GITHUB_CLIENT_ID, env.FIRST_PARTY_GITHUB_CLIENT_SECRET, { + name: "github", + authorizationUrl: + env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize", + tokenUrl: env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token", + integrations: [IntegrationSlug.make("github_rest")], + authorizationScopes: [], + }), + ...client(env.FIRST_PARTY_GITLAB_CLIENT_ID, env.FIRST_PARTY_GITLAB_CLIENT_SECRET, { + name: "gitlab", + authorizationUrl: "https://gitlab.com/oauth/authorize", + tokenUrl: "https://gitlab.com/oauth/token", + authorizationScopes: GITLAB_SCOPES, + allowedScopes: GITLAB_SCOPES, + }), + ...client(env.FIRST_PARTY_GOOGLE_CLIENT_ID, env.FIRST_PARTY_GOOGLE_CLIENT_SECRET, { + name: "google", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + allowedScopes: GOOGLE_ALLOWED_SCOPES, + }), + ...client(env.FIRST_PARTY_HUBSPOT_CLIENT_ID, env.FIRST_PARTY_HUBSPOT_CLIENT_SECRET, { + name: "hubspot", + authorizationUrl: "https://app.hubspot.com/oauth/authorize", + tokenUrl: "https://api.hubapi.com/oauth/v3/token", + authorizationScopes: HUBSPOT_REQUIRED_SCOPES, + allowedScopes: [...HUBSPOT_REQUIRED_SCOPES, ...HUBSPOT_OPTIONAL_SCOPES], + authorizationExtraParams: { optional_scope: HUBSPOT_OPTIONAL_SCOPES.join(" ") }, + }), + ...client(env.FIRST_PARTY_LINEAR_CLIENT_ID, env.FIRST_PARTY_LINEAR_CLIENT_SECRET, { + name: "linear", + authorizationUrl: "https://linear.app/oauth/authorize", + tokenUrl: "https://api.linear.app/oauth/token", + authorizationScopes: ["read", "write"], + authorizationScopeSeparator: ",", + allowedScopes: ["read", "write"], + }), + ...client(env.FIRST_PARTY_MICROSOFT_CLIENT_ID, env.FIRST_PARTY_MICROSOFT_CLIENT_SECRET, { + name: "microsoft", + authorizationUrl: MICROSOFT_AUTHORIZATION_URL, + tokenUrl: MICROSOFT_TOKEN_URL, + allowedScopes: MICROSOFT_SCOPES, + additionalAuthorizationScopes: ["offline_access"], + }), + ...client(env.FIRST_PARTY_NOTION_CLIENT_ID, env.FIRST_PARTY_NOTION_CLIENT_SECRET, { + name: "notion", + authorizationUrl: "https://api.notion.com/v1/oauth/authorize", + tokenUrl: "https://api.notion.com/v1/oauth/token", + authorizationExtraParams: { owner: "user" }, + tokenEndpointAuthMethod: "basic", + tokenRequestFormat: "json", + authorizationScopes: [], + }), + ...client(env.FIRST_PARTY_SLACK_CLIENT_ID, env.FIRST_PARTY_SLACK_CLIENT_SECRET, { + name: "slack", + authorizationUrl: "https://slack.com/oauth/v2_user/authorize", + tokenUrl: "https://slack.com/api/oauth.v2.user.access", + resource: "https://mcp.slack.com", + integrations: [IntegrationSlug.make("slack")], + allowedScopes: slackMcpUserScopes, + }), +]; diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index ef0b54da4..c4e5bfbdf 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -65,6 +65,16 @@ declare global { // unset pair simply ships no first-party app for that provider. The // registered callback on the provider side must be // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`. + FIRST_PARTY_AIRTABLE_CLIENT_ID?: string; + FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string; + FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string; + FIRST_PARTY_ATLASSIAN_CLIENT_SECRET?: string; + FIRST_PARTY_BOX_CLIENT_ID?: string; + FIRST_PARTY_BOX_CLIENT_SECRET?: string; + FIRST_PARTY_CLICKUP_CLIENT_ID?: string; + FIRST_PARTY_CLICKUP_CLIENT_SECRET?: string; + FIRST_PARTY_FIGMA_CLIENT_ID?: string; + FIRST_PARTY_FIGMA_CLIENT_SECRET?: string; FIRST_PARTY_GITHUB_CLIENT_ID?: string; FIRST_PARTY_GITHUB_CLIENT_SECRET?: string; // Endpoint overrides for the GitHub first-party app, so tests/dev can @@ -72,8 +82,18 @@ declare global { // production (the real github.com endpoints are the defaults). FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string; FIRST_PARTY_GITHUB_TOKEN_URL?: string; + FIRST_PARTY_GITLAB_CLIENT_ID?: string; + FIRST_PARTY_GITLAB_CLIENT_SECRET?: string; FIRST_PARTY_GOOGLE_CLIENT_ID?: string; FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string; + FIRST_PARTY_HUBSPOT_CLIENT_ID?: string; + FIRST_PARTY_HUBSPOT_CLIENT_SECRET?: string; + FIRST_PARTY_LINEAR_CLIENT_ID?: string; + FIRST_PARTY_LINEAR_CLIENT_SECRET?: string; + FIRST_PARTY_MICROSOFT_CLIENT_ID?: string; + FIRST_PARTY_MICROSOFT_CLIENT_SECRET?: string; + FIRST_PARTY_NOTION_CLIENT_ID?: string; + FIRST_PARTY_NOTION_CLIENT_SECRET?: string; FIRST_PARTY_SLACK_CLIENT_ID?: string; FIRST_PARTY_SLACK_CLIENT_SECRET?: string; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 04c742063..96076c228 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1777,6 +1777,8 @@ export const createExecutor = >; + /** Token endpoint client-auth transport. Omitted means + * `client_secret_post`; `basic` sends the secret only in HTTP Basic auth. */ + readonly tokenEndpointAuthMethod?: "body" | "basic"; + /** Token endpoint request encoding. OAuth defaults to URL-encoded form; + * providers such as Atlassian, ClickUp, and Notion require JSON. */ + readonly tokenRequestFormat?: "form" | "json"; /** OAuth scopes this deployment permits the app to request. Omit to allow * every scope declared by a matching integration. For declared scopes, * start and completion fail unless every requested scope belongs to this diff --git a/packages/core/sdk/src/oauth-first-party.test.ts b/packages/core/sdk/src/oauth-first-party.test.ts index d9070bcea..530ff675a 100644 --- a/packages/core/sdk/src/oauth-first-party.test.ts +++ b/packages/core/sdk/src/oauth-first-party.test.ts @@ -199,6 +199,42 @@ describe("first-party oauth clients", () => { ), ); + it.effect("applies first-party lifecycle scopes, separators, and authorize parameters", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read", "offline_access"] }); + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [ + { + ...firstPartyClientFor(server), + allowedScopes: ["read", "offline_access"], + additionalAuthorizationScopes: ["offline_access"], + authorizationScopeSeparator: ",", + authorizationExtraParams: { audience: "api.example.com", prompt: "consent" }, + }, + ], + }); + yield* executor.acme.seed(["read"]); + + const started = yield* executor.oauth.start({ + owner: "org", + client: FIRST_PARTY, + clientOwner: "org", + name: ConnectionName.make("lifecycle"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const authorizationUrl = new URL(started.authorizationUrl); + expect(authorizationUrl.searchParams.get("scope")).toBe("read,offline_access"); + expect(authorizationUrl.searchParams.get("audience")).toBe("api.example.com"); + expect(authorizationUrl.searchParams.get("prompt")).toBe("consent"); + }), + ), + ); + it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 0f39c391b..776d2814b 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -6,7 +6,7 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Ref } from "effect"; +import { Effect, Exit, Ref, Schema } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; import { @@ -30,8 +30,11 @@ interface TokenCall { readonly url: string; readonly headers: Readonly>; readonly body: URLSearchParams; + readonly jsonBody: unknown; } +const decodeJsonBody = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse; const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse => @@ -48,6 +51,9 @@ const serveTokenEndpoint = (handler: TokenHandler) => url: request.url ?? "/", headers: request.headers, body: new URLSearchParams(bodyText), + jsonBody: request.headers["content-type"]?.startsWith("application/json") + ? decodeJsonBody(bodyText) + : null, }; yield* Ref.update(calls, (all) => [...all, call]); return handler(call); @@ -256,6 +262,56 @@ describe("buildAuthorizationUrl", () => { }); describe("exchangeAuthorizationCode", () => { + it.effect("supports JSON token exchange with HTTP Basic client authentication", () => + withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => + Effect.gen(function* () { + yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + clientSecret: "csecret", + redirectUrl: "https://app.example.com/cb", + codeVerifier: "verifier", + code: "abc", + clientAuth: "basic", + requestFormat: "json", + }); + const call = (yield* calls)[0]!; + expect(call.headers["content-type"]).toBe("application/json"); + expect(call.headers["authorization"]).toBe("Basic Y2lkOmNzZWNyZXQ="); + expect(call.jsonBody).toEqual({ + grant_type: "authorization_code", + code: "abc", + redirect_uri: "https://app.example.com/cb", + code_verifier: "verifier", + }); + }), + ), + ); + + it.effect("supports JSON token exchange with client credentials in the body", () => + withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => + Effect.gen(function* () { + yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + clientSecret: "csecret", + redirectUrl: "https://app.example.com/cb", + codeVerifier: "verifier", + code: "abc", + requestFormat: "json", + }); + expect((yield* calls)[0]!.jsonBody).toEqual({ + grant_type: "authorization_code", + code: "abc", + redirect_uri: "https://app.example.com/cb", + code_verifier: "verifier", + client_id: "cid", + client_secret: "csecret", + }); + }), + ), + ); + it.effect("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", () => withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => Effect.gen(function* () { @@ -904,6 +960,31 @@ describe("exchangeClientCredentials", () => { }); describe("refreshAccessToken", () => { + it.effect("persists provider-compatible JSON refresh rotation requests", () => + withTokenEndpoint( + tokenResponse({ ...validRefreshBody, refresh_token: "rotated" }), + ({ tokenUrl, calls }) => + Effect.gen(function* () { + const result = yield* refreshAccessToken({ + tokenUrl, + clientId: "cid", + clientSecret: "csecret", + refreshToken: "old", + scopes: ["read", "offline_access"], + requestFormat: "json", + }); + expect(result.refresh_token).toBe("rotated"); + expect((yield* calls)[0]!.jsonBody).toEqual({ + grant_type: "refresh_token", + refresh_token: "old", + scope: "read offline_access", + client_id: "cid", + client_secret: "csecret", + }); + }), + ), + ); + it.effect("normalizes Slack's comma-delimited scopes on refresh", () => Effect.gen(function* () { const result = yield* refreshAccessToken({ diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index f6ccc7a53..a1db209f3 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -749,6 +749,9 @@ export type ExchangeAuthorizationCodeInput = { readonly codeVerifier: string; readonly code: string; readonly clientAuth?: ClientAuthMethod; + /** Encoding required by the provider's token endpoint. OAuth defaults to + * URL-encoded form; a small set of providers require a JSON object. */ + readonly requestFormat?: "form" | "json"; readonly idTokenSigningAlgValuesSupported?: readonly string[]; /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on * the token request when the client knows the resource it intends @@ -759,6 +762,59 @@ export type ExchangeAuthorizationCodeInput = { readonly fetch?: typeof globalThis.fetch; }; +const base64BasicCredentials = (clientId: string, clientSecret: string): string => { + const bytes = new TextEncoder().encode(`${clientId}:${clientSecret}`); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return globalThis.btoa(binary); +}; + +const jsonTokenEndpointRequest = async (input: { + readonly tokenUrl: string; + readonly clientId: string; + readonly clientSecret?: string | null; + readonly clientAuth: ClientAuthMethod; + readonly grantType: "authorization_code" | "refresh_token"; + readonly parameters: Readonly>; + readonly timeoutMs?: number; + readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; + readonly fetch?: typeof globalThis.fetch; +}): Promise => { + const tokenUrl = assertSupportedOAuthEndpointUrl( + input.tokenUrl, + "Token URL", + input.endpointUrlPolicy, + ); + const headers = new Headers({ + accept: "application/json", + "content-type": "application/json", + }); + const confidential = Boolean(input.clientSecret); + if (confidential && input.clientAuth === "basic") { + headers.set( + "authorization", + `Basic ${base64BasicCredentials(input.clientId, input.clientSecret ?? "")}`, + ); + } + const body = { + grant_type: input.grantType, + ...input.parameters, + ...(confidential && input.clientAuth === "basic" + ? {} + : { + client_id: input.clientId, + ...(confidential ? { client_secret: input.clientSecret ?? "" } : {}), + }), + }; + // oxlint-disable-next-line executor/no-raw-fetch -- boundary: provider token exchange is the SDK's HTTP boundary and preserves its injected fetch seam + return await (input.fetch ?? globalThis.fetch)(tokenUrl, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS), + }); +}; + export const exchangeAuthorizationCode = ( input: ExchangeAuthorizationCodeInput, ): Effect.Effect => @@ -786,19 +842,32 @@ export const exchangeAuthorizationCode = ( if (input.resource) { params.set("resource", input.resource); } - const response = await oauth.genericTokenEndpointRequest( - as, - client, - clientAuth, - "authorization_code", - params, - oauth4webapiRequestOptions( - input.tokenUrl, - input.timeoutMs, - input.endpointUrlPolicy, - input.fetch, - ), - ); + const response = + input.requestFormat === "json" + ? await jsonTokenEndpointRequest({ + tokenUrl: input.tokenUrl, + clientId: input.clientId, + clientSecret: input.clientSecret, + clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, + grantType: "authorization_code", + parameters: Object.fromEntries(params), + timeoutMs: input.timeoutMs, + endpointUrlPolicy: input.endpointUrlPolicy, + fetch: input.fetch, + }) + : await oauth.genericTokenEndpointRequest( + as, + client, + clientAuth, + "authorization_code", + params, + oauth4webapiRequestOptions( + input.tokenUrl, + input.timeoutMs, + input.endpointUrlPolicy, + input.fetch, + ), + ); return await processTokenEndpointResponse(as, client, response); }, catch: (cause) => cause, @@ -888,6 +957,9 @@ export type RefreshAccessTokenInput = { readonly scopes?: readonly string[]; readonly scopeSeparator?: string; readonly clientAuth?: ClientAuthMethod; + /** Encoding required by the provider's token endpoint. OAuth defaults to + * URL-encoded form; a small set of providers require a JSON object. */ + readonly requestFormat?: "form" | "json"; readonly idTokenSigningAlgValuesSupported?: readonly string[]; /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on * refresh requests so the new access token's audience is bound to @@ -921,6 +993,26 @@ export const refreshAccessToken = ( } const additionalParameters = Array.from(extraParams.keys()).length > 0 ? extraParams : undefined; + if (input.requestFormat === "json") { + const response = await jsonTokenEndpointRequest({ + tokenUrl: input.tokenUrl, + clientId: input.clientId, + clientSecret: input.clientSecret, + clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, + grantType: "refresh_token", + parameters: { + refresh_token: input.refreshToken, + ...(input.scopes && input.scopes.length > 0 + ? { scope: input.scopes.join(input.scopeSeparator ?? " ") } + : {}), + ...(input.resource ? { resource: input.resource } : {}), + }, + timeoutMs: input.timeoutMs, + endpointUrlPolicy: input.endpointUrlPolicy, + fetch: input.fetch, + }); + return await processTokenEndpointResponse(as, client, response); + } const response = await oauth.refreshTokenGrantRequest( as, client, diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 8ec30c679..80e43dacd 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -412,6 +412,8 @@ interface LoadedOAuthClient { /** Resolved literal secret (read from the provider via the stored item id). */ readonly clientSecret: string; readonly resource: string | null; + readonly tokenEndpointAuthMethod?: "body" | "basic"; + readonly tokenRequestFormat?: "form" | "json"; } /** Where an OAuth app's client secret is stored in the default writable @@ -514,6 +516,8 @@ export const loadedFirstPartyClient = ( readonly clientId: string; readonly clientSecret: string; readonly resource: string | null; + readonly tokenEndpointAuthMethod?: "body" | "basic"; + readonly tokenRequestFormat?: "form" | "json"; } => ({ slug: String(firstPartyOAuthClientSlug(config.name)), authorizationUrl: config.authorizationUrl, @@ -522,6 +526,12 @@ export const loadedFirstPartyClient = ( clientId: config.clientId, clientSecret: config.clientSecret, resource: config.resource ?? null, + ...(config.tokenEndpointAuthMethod === undefined + ? {} + : { tokenEndpointAuthMethod: config.tokenEndpointAuthMethod }), + ...(config.tokenRequestFormat === undefined + ? {} + : { tokenRequestFormat: config.tokenRequestFormat }), }); export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { @@ -1307,6 +1317,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { : scopePolicy.kind === "discover" ? requestedScopes : yield* filterAuthorizationCodeScopes(client, requestedScopes); + const completeAuthorizationScopes = dedupeScopes([ + ...authorizationRequestedScopes, + ...(firstParty?.additionalAuthorizationScopes ?? []), + ]); // authorization_code: persist a session + build the authorize URL. const verifier = createPkceCodeVerifier(); @@ -1339,7 +1353,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { payload: { owner: input.owner, clientOwner: input.clientOwner, - requestedScopes: authorizationRequestedScopes, + requestedScopes: completeAuthorizationScopes, }, expires_at: expiresAt, created_at: now, @@ -1352,14 +1366,18 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { authorizationUrl: client.authorizationUrl, clientId: client.clientId, redirectUrl: flowRedirectUri, - scopes: authorizationRequestedScopes, + scopes: completeAuthorizationScopes, state: providerState, codeChallenge: challenge, + scopeSeparator: firstParty?.authorizationScopeSeparator, resource: client.resource ?? undefined, // Provider quirks (Google: access_type=offline + prompt=consent) — // without these Google returns no refresh token and won't re-consent // to widen scopes on reconnect. - extraParams: providerAuthorizeExtras(client.authorizationUrl), + extraParams: { + ...providerAuthorizeExtras(client.authorizationUrl), + ...(firstParty?.authorizationExtraParams ?? {}), + }, endpointUrlPolicy: deps.endpointUrlPolicy, }), catch: (cause) => @@ -1478,6 +1496,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { redirectUrl: session.redirectUrl, codeVerifier: session.pkceVerifier, code: input.code, + clientAuth: client.tokenEndpointAuthMethod, + requestFormat: client.tokenRequestFormat, resource: client.resource ?? undefined, endpointUrlPolicy: deps.endpointUrlPolicy, fetch, diff --git a/packages/plugins/openapi/src/sdk/presets.ts b/packages/plugins/openapi/src/sdk/presets.ts index 6ecf2cd3d..49fc0b617 100644 --- a/packages/plugins/openapi/src/sdk/presets.ts +++ b/packages/plugins/openapi/src/sdk/presets.ts @@ -40,10 +40,10 @@ export const FIGMA_SUPPORTED_OAUTH_SCOPES = [ "file_dev_resources:write", "file_metadata:read", "file_versions:read", + "folder_metadata:read", + "folders:read", "library_assets:read", "library_content:read", - "project_metadata:read", - "projects:read", "team_library_content:read", "webhooks:read", "webhooks:write",