diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index 230f028fd..ef13ed563 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -42,19 +42,14 @@ 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 { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout"; import { DbService } from "../db/db"; import { cloudDbProviderLayer } from "../db/fuma"; +import { firstPartyOAuthClientsFor } from "./first-party-oauth-clients"; export { makeExecutionStack } from "@executor-js/api/server"; @@ -95,98 +90,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. -export 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, - // Withdrawn from the connect picker: no new connection is offered the - // Executor-owned Google app. The entry stays declared on purpose — - // every connection already minted against it keeps refreshing and - // reconnecting through it. Deleting this block, or unsetting the env - // vars, would strand those connections instead. - unlisted: true, - }, - ] - : []), - ...(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`); @@ -198,7 +101,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), // Workers cancel request-scoped I/O once the response settles; the ambient // `waitUntil` binds to the in-flight invocation (HTTP request or DO call), // so stale tool-catalog rebuilds that outlive a read still converge. diff --git a/apps/cloud/src/engine/first-party-oauth-clients.test.ts b/apps/cloud/src/engine/first-party-oauth-clients.test.ts index 9780a1c62..fe8c22056 100644 --- a/apps/cloud/src/engine/first-party-oauth-clients.test.ts +++ b/apps/cloud/src/engine/first-party-oauth-clients.test.ts @@ -1,7 +1,100 @@ -import { env } from "cloudflare:workers"; -import { beforeAll, describe, expect, it } from "@effect/vitest"; +import { describe, expect, it } from "@effect/vitest"; -import { cloudFirstPartyOAuthClients } from "./execution-stack"; +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", + }); + }); +}); // The reviewed consumer scope boundary of the Executor-owned Google app. // @@ -12,13 +105,9 @@ import { cloudFirstPartyOAuthClients } from "./execution-stack"; // scopes an `oauth.start` requests, and that admin scopes are refused). const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`; -describe("cloud first-party oauth clients", () => { - beforeAll(() => { - env.FIRST_PARTY_GOOGLE_CLIENT_ID = "test-google-client"; - env.FIRST_PARTY_GOOGLE_CLIENT_SECRET = "test-google-secret"; - }); - - const google = () => cloudFirstPartyOAuthClients().find((client) => client.name === "google"); +describe("cloud first-party Google app", () => { + const google = () => + firstPartyOAuthClientsFor(completeEnv).find((client) => client.name === "google"); it("declares the Google app but withholds it from every listing", () => { const client = google(); 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..8b8ba02b7 --- /dev/null +++ b/apps/cloud/src/engine/first-party-oauth-clients.ts @@ -0,0 +1,336 @@ +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; + +// 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_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", + ]), +]; + +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. + * + * 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. */ +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")], + // GitHub App user access tokens do not use classic OAuth scopes; their + // capabilities come from the app's registered permissions. + 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, + // Withdrawn from the connect picker: no new connection is offered the + // Executor-owned Google app. The entry stays declared on purpose — every + // connection already minted against it keeps refreshing and reconnecting + // through it. Deleting this block, or unsetting the env vars, would strand + // those connections instead. + unlisted: true, + }), + ...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 bb31aa00a..8105ce9c4 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -72,6 +72,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 @@ -79,8 +89,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 7c3c25929..02e75d30e 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1955,6 +1955,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"; /** Withdraw the app from every listing surface without retiring it. It stops * appearing in `listClients` — so connect pickers and the agent-facing * client list never offer it — while remaining fully resolvable by slug. diff --git a/packages/core/sdk/src/oauth-first-party.test.ts b/packages/core/sdk/src/oauth-first-party.test.ts index 848c2fd53..c1ba29274 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 ab5dcb903..99e7090ab 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -6,7 +6,8 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Ref } from "effect"; +import { Cause, Effect, Exit, Ref, Schema } from "effect"; +import type * as Tracer from "effect/Tracer"; import { HttpServerResponse } from "effect/unstable/http"; import { @@ -34,8 +35,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 => @@ -52,6 +56,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); @@ -103,6 +110,45 @@ const tokenResponse = () => json(200, body); +/** Records each span's attributes and its ending exit, so a test can assert on + * exactly what telemetry would export for the token-request span. */ +interface RecordedSpan { + readonly attributes: Map; + endExit?: unknown; +} + +const makeRecordingTracer = (spans: Map): Tracer.Tracer => ({ + span: (options) => { + const record: RecordedSpan = { attributes: new Map() }; + spans.set(options.name, record); + let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime }; + return { + _tag: "Span", + name: options.name, + spanId: "0000000000000001", + traceId: "00000000000000000000000000000001", + parent: options.parent, + annotations: options.annotations, + get status() { + return status; + }, + attributes: record.attributes, + links: options.links, + sampled: options.sampled, + kind: options.kind, + end: (endTime, exit) => { + record.endExit = exit; + status = { _tag: "Ended", startTime: options.startTime, endTime, exit }; + }, + attribute: (key, value) => { + record.attributes.set(key, value); + }, + event: () => undefined, + addLinks: () => undefined, + }; + }, +}); + const tokenResponseFetch = (body: unknown): typeof globalThis.fetch => async () => @@ -260,6 +306,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* () { @@ -765,6 +861,9 @@ describe("exchangeAuthorizationCode", () => { }), ); + // Non-secret description text must keep propagating even from a confidential + // client: the scrub below removes ONLY the exact submitted secret, never the + // AS's verdict prose around it. it.effect("propagates RFC 6749 error_description text in the OAuth2Error", () => withTokenEndpoint( () => @@ -778,6 +877,7 @@ describe("exchangeAuthorizationCode", () => { exchangeAuthorizationCode({ tokenUrl, clientId: "cid", + clientSecret: "csecret", redirectUrl: "https://cb", codeVerifier: "v", code: "c", @@ -786,10 +886,67 @@ describe("exchangeAuthorizationCode", () => { expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) return; expect(JSON.stringify(exit.cause)).toContain("Code expired"); + const failure = Cause.squash(exit.cause) as OAuth2Error; + expect(failure.error).toBe("invalid_grant"); }), ), ); + // The review's canary for the first-party secret leak. A provider that + // echoes the submitted client_secret inside error_description would carry a + // DEPLOYMENT-WIDE credential into the OAuth2Error message — and from there + // into OAuthCompleteError, the popup's browser-visible errorDetails, and the + // token-request span. Assert on the WHOLE rendered failure, not just the + // message: what a log line or a `JSON.stringify` prints includes the retained + // rejection, so an echo surviving anywhere in the cause is still a leak. + it.effect("scrubs a client secret echoed in error_description from the whole failure", () => { + const spans = new Map(); + return withTokenEndpoint( + () => + json(400, { + error: "invalid_client", + error_description: + "authentication failed for secret SECRET-CANARY-must-not-escape, check your credentials", + }), + ({ tokenUrl }) => + Effect.gen(function* () { + const exit = yield* Effect.exit( + exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + clientSecret: "SECRET-CANARY-must-not-escape", + redirectUrl: "https://cb", + codeVerifier: "v", + code: "c", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + for (const rendered of [Cause.pretty(exit.cause), JSON.stringify(exit.cause)]) { + expect(rendered).not.toContain("SECRET-CANARY-must-not-escape"); + } + const failure = Cause.squash(exit.cause) as OAuth2Error; + expect(failure).toBeInstanceOf(OAuth2Error); + // Masked, not dropped: the verdict prose around the secret survives, + // and so does everything classification and the span read. + expect(failure.message).toContain("[redacted]"); + expect(failure.message).toContain("check your credentials"); + expect(failure.message).not.toContain("SECRET-CANARY-must-not-escape"); + expect(failure.error).toBe("invalid_client"); + expect(failure.status).toBe(400); + // The token-request span — attributes AND ending exit — is what the + // exporter sees; the echo must not survive there either. + const tokenSpan = spans.get("executor.oauth.token_request"); + expect(tokenSpan?.attributes.get("executor.oauth.error_code")).toBe("invalid_client"); + const spanRendered = JSON.stringify({ + attributes: [...(tokenSpan?.attributes ?? [])], + exit: tokenSpan?.endExit, + }); + expect(spanRendered).not.toContain("SECRET-CANARY-must-not-escape"); + }), + ).pipe(Effect.withTracer(makeRecordingTracer(spans))); + }); + it.effect("includes HTTP status and body preview for non-OAuth token endpoint errors", () => withTokenEndpoint( () => HttpServerResponse.text("route not found", { status: 404 }), @@ -1192,6 +1349,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 53a4ce688..f3cb2baa9 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -641,8 +641,23 @@ const oauthErrorFromResponseBody = ( }); }; -const toOAuth2Error = (cause: unknown): OAuth2Error => { +/** Exact-string scrub of the submitted client secret from text bound for an + * `OAuth2Error`. A misbehaving authorization server can echo the credential it + * was just sent back inside `error_description` — a field that is deliberately + * previewable everywhere else in this module because it is the AS's + * human-readable verdict. The message it lands in reaches the OAuth popup's + * browser-visible error details, persisted connection health, and telemetry, + * and for a first-party client the secret is deployment-wide. The helper knows + * exactly which string it submitted, so this is a targeted replacement of that + * one value, not a heuristic. */ +const redactSubmittedClientSecret = ( + text: string, + clientSecret: string | null | undefined, +): string => (clientSecret ? text.replaceAll(clientSecret, "[redacted]") : text); + +const toOAuth2Error = (cause: unknown, clientSecret?: string | null): OAuth2Error => { if (isOAuth2Error(cause)) return cause; + const scrub = (text: string): string => redactSubmittedClientSecret(text, clientSecret); if (typeof cause === "object" && cause !== null) { const c = cause as { error?: unknown; @@ -657,8 +672,10 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { ? c.message : undefined; return new OAuth2Error({ - message: `OAuth token exchange failed: ${description ?? code ?? "unknown error"}`, - error: code, + message: scrub(`OAuth token exchange failed: ${description ?? code ?? "unknown error"}`), + // The code is scrubbed too: a conform envelope's `error` is an arbitrary + // string the AS chose, and it flows into the token-request span attribute. + error: code === undefined ? undefined : scrub(code), cause, }); } @@ -674,13 +691,18 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { * * `fallbackMessage` is for the paths that hold the error Response directly * rather than catching a thrown oauth4webapi error: there is no library - * message to build on, so the caller names the step instead. */ + * message to build on, so the caller names the step instead. + * + * `clientSecret` is the secret the failed request submitted, when the client + * is confidential. Every constructed message is scrubbed of it, because the + * body it is built from is the AS's and may echo the credential back. */ const toOAuth2ErrorWithHttpSummary = ( cause: unknown, - options?: { readonly fallbackMessage?: string }, + options?: { readonly fallbackMessage?: string; readonly clientSecret?: string | null }, ): Effect.Effect => { if (isOAuth2Error(cause)) return Effect.succeed(cause); - const base = toOAuth2Error(cause); + const scrub = (text: string): string => redactSubmittedClientSecret(text, options?.clientSecret); + const base = toOAuth2Error(cause, options?.clientSecret); const response = responseFromOAuthErrorCause(cause); if (!response) { // No Response, but possibly a body the library already parsed off one it @@ -704,8 +726,8 @@ const toOAuth2ErrorWithHttpSummary = ( // exactly the leak the message allowlist exists to prevent. The other // branches keep their cause because theirs is diagnostic, not a body. new OAuth2Error({ - message: `${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`, - error: base.error ?? envelope?.error, + message: scrub(`${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`), + error: base.error ?? (envelope?.error === undefined ? undefined : scrub(envelope.error)), status: PARSED_BODY_CAUSE_STATUS, }), ); @@ -727,20 +749,37 @@ const toOAuth2ErrorWithHttpSummary = ( : `${headline}: ${recovered.code}${ recovered.description === undefined ? "" : ` — ${recovered.description}` }`; + const code = base.error ?? recovered?.code; + // The diagnostic rejection is dropped when the submitted secret is visible + // in its serialised form — oauth4webapi's ResponseBodyError carries the + // AS's `error_description` as an own enumerable field, so anything that + // renders the WHOLE failure (`Cause.pretty`, `JSON.stringify`) would + // replay the echo straight past the message scrub above. Everything + // classification reads — status, code, HTTP summary — is already lifted + // out, mirroring the malformed-200 branch's treatment of token-bearing + // rejections; the ordinary no-echo failure keeps its cause untouched. + const causeEchoesSecret = + Boolean(options?.clientSecret) && safeStringify(cause).includes(options?.clientSecret ?? ""); return new OAuth2Error({ - message: `${described} (${summary})`, - error: base.error ?? recovered?.code, + message: scrub(`${described} (${summary})`), + error: code === undefined ? undefined : scrub(code), // Carried even when no code was recovered: the status is what tells a // caller whether the AS refused (4xx — permanent, stop) or stumbled (5xx // — retry). Most real refusals arrive with no code at all. status: response.status, - cause, + ...(causeEchoesSecret ? {} : { cause }), }); }); }; -const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect => - toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error))); +/** Curried on the submitted client secret so every `Effect.catch` site names + * the credential its request carried — the scrub cannot work without it. */ +const failOAuth2WithHttpSummary = + (clientSecret: string | null | undefined) => + (cause: unknown): Effect.Effect => + toOAuth2ErrorWithHttpSummary(cause, { clientSecret }).pipe( + Effect.flatMap((error) => Effect.fail(error)), + ); /** Fail from a token-endpoint error Response the caller holds directly — the * `genericTokenEndpointRequest` paths, where oauth4webapi hands back the raw @@ -751,8 +790,9 @@ const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect => - toOAuth2ErrorWithHttpSummary(response, { fallbackMessage }).pipe( + toOAuth2ErrorWithHttpSummary(response, { fallbackMessage, clientSecret }).pipe( Effect.flatMap((error) => Effect.fail(error)), ); @@ -1069,6 +1109,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 @@ -1079,6 +1122,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 => @@ -1106,24 +1202,37 @@ 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, }).pipe( - Effect.catch(failOAuth2WithHttpSummary), + Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), withTokenRequestSpan({ grantType: "authorization_code", tokenUrl: input.tokenUrl, @@ -1186,7 +1295,7 @@ export const exchangeClientCredentials = ( }, catch: (cause) => cause, }).pipe( - Effect.catch(failOAuth2WithHttpSummary), + Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), withTokenRequestSpan({ grantType: "client_credentials", tokenUrl: input.tokenUrl, @@ -1208,6 +1317,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 @@ -1241,6 +1353,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, @@ -1265,7 +1397,7 @@ export const refreshAccessToken = ( }, catch: (cause) => cause, }).pipe( - Effect.catch(failOAuth2WithHttpSummary), + Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), withTokenRequestSpan({ grantType: "refresh_token", tokenUrl: input.tokenUrl, @@ -1375,10 +1507,14 @@ export const exchangeSubjectTokenForIdJag = ( ); }, catch: (cause) => cause, - }).pipe(Effect.catch(failOAuth2WithHttpSummary)); + }).pipe(Effect.catch(failOAuth2WithHttpSummary(input.clientSecret))); if (!response.ok) { - return yield* failOAuth2FromErrorResponse(response, "ID-JAG token exchange was rejected"); + return yield* failOAuth2FromErrorResponse( + response, + "ID-JAG token exchange was rejected", + input.clientSecret, + ); } // Nothing else reads this body, so it is consumed directly. A read failure @@ -1485,7 +1621,7 @@ export const redeemIdJagAssertion = ( }, catch: (cause) => cause, }).pipe( - Effect.catch(failOAuth2WithHttpSummary), + Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), withTokenRequestSpan({ grantType: JWT_BEARER_GRANT_TYPE, tokenUrl: input.tokenUrl, diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 9feb2bcbd..62260893d 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -509,6 +509,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 @@ -611,6 +613,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, @@ -619,6 +623,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 => { @@ -1683,6 +1693,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(); @@ -1744,7 +1758,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { payload: { owner: input.owner, clientOwner: input.clientOwner, - requestedScopes: authorizationRequestedScopes, + requestedScopes: completeAuthorizationScopes, }, expires_at: expiresAt, created_at: now, @@ -1757,14 +1771,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) => @@ -1883,6 +1901,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 c093a71a1..a5ecc8a56 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",