History

Add registered first-party OAuth providers (#1717)

* Add registered first-party OAuth providers * Redact submitted client secret from token-request failures

Rhys Sullivan committed 2 months ago

Commit ad702a91 parent c1f51b711 files · +901 −150Browse files

Unified · Split

11 files
  1. apps/cloud/src/engine/execution-stack.ts +3 −100
  2. apps/cloud/src/engine/first-party-oauth-clients.test.ts +99 −10
  3. apps/cloud/src/engine/first-party-oauth-clients.ts +336 −0
  4. apps/cloud/src/env-augment.d.ts +20 −0
  5. packages/core/sdk/src/executor.ts +11 −1
  6. packages/core/sdk/src/oauth-client.ts +19 −0
  7. packages/core/sdk/src/oauth-first-party.test.ts +36 −0
  8. packages/core/sdk/src/oauth-helpers.test.ts +183 −1
  9. packages/core/sdk/src/oauth-helpers.ts +169 −33
  10. packages/core/sdk/src/oauth-service.ts +23 −3
  11. packages/plugins/openapi/src/sdk/presets.ts +2 −2
apps/cloud/src/engine/execution-stack.ts+3−100
@@ -42,19 +42,14 @@ import { · 41 unmodified lines
4242 PluginsProvider,
4343 collectTables,
4444 } from "@executor-js/api/server";
45−import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google";
46−import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
4745 import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";
48−import {
49− IntegrationSlug,
50− type AnyPlugin,
51− type FirstPartyOAuthClientConfig,
52−} from "@executor-js/sdk";
46+import { type AnyPlugin } from "@executor-js/sdk";
5347
5448 import executorConfig from "../../executor.config";
5549 import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout";
5650 import { DbService } from "../db/db";
5751 import { cloudDbProviderLayer } from "../db/fuma";
52+import { firstPartyOAuthClientsFor } from "./first-party-oauth-clients";
5853
5954 export { makeExecutionStack } from "@executor-js/api/server";
6055
@@ -95,98 +90,6 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 34 unmodified lines
9590 */
9691 export const CLOUD_MOUNT_PREFIX = "/api" as const;
9792
98−// Consumer Google launch boundary. Keep this list aligned with the scopes
99−// submitted for the Executor-owned production app: ordinary Workspace services
100−// plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script,
101−// BigQuery, and Cloud Resource Manager have materially different audiences or
102−// provider requirements and remain BYO OAuth. The same scope source builds each
103−// catalog auth template, preventing picker/start drift.
104−const GOOGLE_FIRST_PARTY_PRESET_IDS = [
105− "google-calendar",
106− "google-meet",
107− "google-gmail",
108− "google-sheets",
109− "google-drive",
110− "google-docs",
111− "google-slides",
112− "google-forms",
113− "google-tasks",
114− "google-people",
115− "google-photos-library",
116− "google-photos-picker",
117− "google-search-console",
118−] as const;
119−
120−const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [
121− ...new Set([
122− ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset),
123− // Connections created before the full-Gmail review retain this declared
124− // scope on reconnect. New Gmail presets request `mail.google.com`.
125− "https://www.googleapis.com/auth/gmail.modify",
126− ]),
127−];
128−
129−// Executor-owned provider apps, enabled per provider by setting BOTH env vars
130−// (id + secret). Each provider-side registration must list
131−// `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug
132−// travels inside OAuth `state`, so the single static callback serves every org.
133−//
134−// The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` /
135−// `_TOKEN_URL` overrides exist so tests and dev instances can point the app at
136−// an emulated provider (`@executor-js/emulate`) and run the complete flow.
137−// Production leaves them unset.
138−export const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] => [
139− ...(env.FIRST_PARTY_GITHUB_CLIENT_ID && env.FIRST_PARTY_GITHUB_CLIENT_SECRET
140− ? [
141− {
142− name: "github",
143− authorizationUrl:
144− env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize",
145− tokenUrl:
146− env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token",
147− clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID,
148− clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET,
149− integrations: [IntegrationSlug.make("github_rest")],
150− // GitHub App user access tokens do not use classic OAuth scopes;
151− // their capabilities come from the app's registered permissions.
152− authorizationScopes: [],
153− },
154− ]
155− : []),
156− ...(env.FIRST_PARTY_GOOGLE_CLIENT_ID && env.FIRST_PARTY_GOOGLE_CLIENT_SECRET
157− ? [
158− {
159− name: "google",
160− authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
161− tokenUrl: "https://oauth2.googleapis.com/token",
162− clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID,
163− clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET,
164− allowedScopes: GOOGLE_FIRST_PARTY_ALLOWED_SCOPES,
165− // Withdrawn from the connect picker: no new connection is offered the
166− // Executor-owned Google app. The entry stays declared on purpose —
167− // every connection already minted against it keeps refreshing and
168− // reconnecting through it. Deleting this block, or unsetting the env
169− // vars, would strand those connections instead.
170− unlisted: true,
171− },
172− ]
173− : []),
174− ...(env.FIRST_PARTY_SLACK_CLIENT_ID && env.FIRST_PARTY_SLACK_CLIENT_SECRET
175− ? [
176− {
177− name: "slack",
178− authorizationUrl: "https://slack.com/oauth/v2_user/authorize",
179− tokenUrl: "https://slack.com/api/oauth.v2.user.access",
180− resource: "https://mcp.slack.com",
181− clientId: env.FIRST_PARTY_SLACK_CLIENT_ID,
182− clientSecret: env.FIRST_PARTY_SLACK_CLIENT_SECRET,
183− integrations: [IntegrationSlug.make("slack")],
184− allowedScopes: slackMcpUserScopes,
185− },
186− ]
187− : []),
188−];
189−
19093 export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({
19194 // SSRF / private-network egress guard. Config-driven, NOT a test flag:
19295 // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);
@@ -198,7 +101,7 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, ( · 5 unmodified lines
198101 // WorkOS Vault is cloud's credential storage implementation detail, not a
199102 // user-selectable provider surface.
200103 exposeCredentialProviders: false,
201− firstPartyOAuthClients: cloudFirstPartyOAuthClients(),
104+ firstPartyOAuthClients: firstPartyOAuthClientsFor(env),
202105 // Workers cancel request-scoped I/O once the response settles; the ambient
203106 // `waitUntil` binds to the in-flight invocation (HTTP request or DO call),
204107 // so stale tool-catalog rebuilds that outlive a read still converge.
apps/cloud/src/engine/first-party-oauth-clients.test.ts+99−10
@@ -1,7 +1,100 @@
1−import { env } from "cloudflare:workers";
2−import { beforeAll, describe, expect, it } from "@effect/vitest";
1+import { describe, expect, it } from "@effect/vitest";
32
4−import { cloudFirstPartyOAuthClients } from "./execution-stack";
3+import {
4+ firstPartyOAuthClientsFor,
5+ type FirstPartyOAuthClientEnv,
6+} from "./first-party-oauth-clients";
7+
8+const completeEnv: FirstPartyOAuthClientEnv = {
9+ FIRST_PARTY_AIRTABLE_CLIENT_ID: "airtable-id",
10+ FIRST_PARTY_AIRTABLE_CLIENT_SECRET: "airtable-secret",
11+ FIRST_PARTY_ATLASSIAN_CLIENT_ID: "atlassian-id",
12+ FIRST_PARTY_ATLASSIAN_CLIENT_SECRET: "atlassian-secret",
13+ FIRST_PARTY_BOX_CLIENT_ID: "box-id",
14+ FIRST_PARTY_BOX_CLIENT_SECRET: "box-secret",
15+ FIRST_PARTY_CLICKUP_CLIENT_ID: "clickup-id",
16+ FIRST_PARTY_CLICKUP_CLIENT_SECRET: "clickup-secret",
17+ FIRST_PARTY_FIGMA_CLIENT_ID: "figma-id",
18+ FIRST_PARTY_FIGMA_CLIENT_SECRET: "figma-secret",
19+ FIRST_PARTY_GITHUB_CLIENT_ID: "github-id",
20+ FIRST_PARTY_GITHUB_CLIENT_SECRET: "github-secret",
21+ FIRST_PARTY_GITLAB_CLIENT_ID: "gitlab-id",
22+ FIRST_PARTY_GITLAB_CLIENT_SECRET: "gitlab-secret",
23+ FIRST_PARTY_GOOGLE_CLIENT_ID: "google-id",
24+ FIRST_PARTY_GOOGLE_CLIENT_SECRET: "google-secret",
25+ FIRST_PARTY_HUBSPOT_CLIENT_ID: "hubspot-id",
26+ FIRST_PARTY_HUBSPOT_CLIENT_SECRET: "hubspot-secret",
27+ FIRST_PARTY_LINEAR_CLIENT_ID: "linear-id",
28+ FIRST_PARTY_LINEAR_CLIENT_SECRET: "linear-secret",
29+ FIRST_PARTY_MICROSOFT_CLIENT_ID: "microsoft-id",
30+ FIRST_PARTY_MICROSOFT_CLIENT_SECRET: "microsoft-secret",
31+ FIRST_PARTY_NOTION_CLIENT_ID: "notion-id",
32+ FIRST_PARTY_NOTION_CLIENT_SECRET: "notion-secret",
33+ FIRST_PARTY_SLACK_CLIENT_ID: "slack-id",
34+ FIRST_PARTY_SLACK_CLIENT_SECRET: "slack-secret",
35+};
36+
37+describe("cloud first-party OAuth clients", () => {
38+ it("enables every registered OAuth 2 provider from complete secret pairs", () => {
39+ const clients = firstPartyOAuthClientsFor(completeEnv);
40+
41+ expect(clients.map((client) => client.name)).toEqual([
42+ "airtable",
43+ "atlassian",
44+ "box",
45+ "clickup",
46+ "figma",
47+ "github",
48+ "gitlab",
49+ "google",
50+ "hubspot",
51+ "linear",
52+ "microsoft",
53+ "notion",
54+ "slack",
55+ ]);
56+ });
57+
58+ it("fails closed when either half of a provider secret pair is absent", () => {
59+ expect(firstPartyOAuthClientsFor({ FIRST_PARTY_AIRTABLE_CLIENT_ID: "id" })).toEqual([]);
60+ expect(firstPartyOAuthClientsFor({ FIRST_PARTY_AIRTABLE_CLIENT_SECRET: "secret" })).toEqual([]);
61+ });
62+
63+ it("carries provider-specific authorization and token contracts", () => {
64+ const byName = new Map(
65+ firstPartyOAuthClientsFor(completeEnv).map((client) => [client.name, client]),
66+ );
67+
68+ expect(byName.get("airtable")).toMatchObject({
69+ tokenEndpointAuthMethod: "basic",
70+ });
71+ expect(byName.get("atlassian")).toMatchObject({
72+ tokenRequestFormat: "json",
73+ authorizationExtraParams: { audience: "api.atlassian.com", prompt: "consent" },
74+ });
75+ expect(byName.get("figma")).toMatchObject({
76+ tokenEndpointAuthMethod: "basic",
77+ allowedScopes: expect.arrayContaining(["folder_metadata:read", "folders:read"]),
78+ });
79+ expect(byName.get("hubspot")).toMatchObject({
80+ tokenUrl: "https://api.hubapi.com/oauth/v3/token",
81+ authorizationExtraParams: {
82+ optional_scope: "content crm.objects.custom.read crm.schemas.custom.read",
83+ },
84+ });
85+ expect(byName.get("linear")).toMatchObject({ authorizationScopeSeparator: "," });
86+ expect(byName.get("microsoft")).toMatchObject({
87+ additionalAuthorizationScopes: ["offline_access"],
88+ allowedScopes: expect.arrayContaining(["Mail.ReadWrite", "Files.ReadWrite.All"]),
89+ });
90+ expect(byName.get("notion")).toMatchObject({
91+ authorizationScopes: [],
92+ authorizationExtraParams: { owner: "user" },
93+ tokenEndpointAuthMethod: "basic",
94+ tokenRequestFormat: "json",
95+ });
96+ });
97+});
598
699 // The reviewed consumer scope boundary of the Executor-owned Google app.
7100 //
@@ -12,13 +105,9 @@ import { cloudFirstPartyOAuthClients } from "./execution-stack"; · 4 unmodified lines
12105 // scopes an `oauth.start` requests, and that admin scopes are refused).
13106 const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`;
14107
15−describe("cloud first-party oauth clients", () => {
16− beforeAll(() => {
17− env.FIRST_PARTY_GOOGLE_CLIENT_ID = "test-google-client";
18− env.FIRST_PARTY_GOOGLE_CLIENT_SECRET = "test-google-secret";
19− });
20−
21− const google = () => cloudFirstPartyOAuthClients().find((client) => client.name === "google");
108+describe("cloud first-party Google app", () => {
109+ const google = () =>
110+ firstPartyOAuthClientsFor(completeEnv).find((client) => client.name === "google");
22111
23112 it("declares the Google app but withholds it from every listing", () => {
24113 const client = google();
apps/cloud/src/engine/first-party-oauth-clients.ts+336−0
@@ -0,0 +1,336 @@
1+import { FIGMA_SUPPORTED_OAUTH_SCOPES } from "@executor-js/plugin-openapi/presets";
2+import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google";
3+import {
4+ MICROSOFT_AUTHORIZATION_URL,
5+ MICROSOFT_TOKEN_URL,
6+} from "@executor-js/plugin-openapi/providers/microsoft";
7+import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
8+import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk";
9+
10+/** Cloud secret bindings that enable host-operated OAuth clients. A provider
11+ * is absent unless both values in its pair are present. */
12+export interface FirstPartyOAuthClientEnv {
13+ readonly FIRST_PARTY_AIRTABLE_CLIENT_ID?: string;
14+ readonly FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string;
15+ readonly FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string;
16+ readonly FIRST_PARTY_ATLASSIAN_CLIENT_SECRET?: string;
17+ readonly FIRST_PARTY_BOX_CLIENT_ID?: string;
18+ readonly FIRST_PARTY_BOX_CLIENT_SECRET?: string;
19+ readonly FIRST_PARTY_CLICKUP_CLIENT_ID?: string;
20+ readonly FIRST_PARTY_CLICKUP_CLIENT_SECRET?: string;
21+ readonly FIRST_PARTY_FIGMA_CLIENT_ID?: string;
22+ readonly FIRST_PARTY_FIGMA_CLIENT_SECRET?: string;
23+ readonly FIRST_PARTY_GITHUB_CLIENT_ID?: string;
24+ readonly FIRST_PARTY_GITHUB_CLIENT_SECRET?: string;
25+ readonly FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string;
26+ readonly FIRST_PARTY_GITHUB_TOKEN_URL?: string;
27+ readonly FIRST_PARTY_GITLAB_CLIENT_ID?: string;
28+ readonly FIRST_PARTY_GITLAB_CLIENT_SECRET?: string;
29+ readonly FIRST_PARTY_GOOGLE_CLIENT_ID?: string;
30+ readonly FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string;
31+ readonly FIRST_PARTY_HUBSPOT_CLIENT_ID?: string;
32+ readonly FIRST_PARTY_HUBSPOT_CLIENT_SECRET?: string;
33+ readonly FIRST_PARTY_LINEAR_CLIENT_ID?: string;
34+ readonly FIRST_PARTY_LINEAR_CLIENT_SECRET?: string;
35+ readonly FIRST_PARTY_MICROSOFT_CLIENT_ID?: string;
36+ readonly FIRST_PARTY_MICROSOFT_CLIENT_SECRET?: string;
37+ readonly FIRST_PARTY_NOTION_CLIENT_ID?: string;
38+ readonly FIRST_PARTY_NOTION_CLIENT_SECRET?: string;
39+ readonly FIRST_PARTY_SLACK_CLIENT_ID?: string;
40+ readonly FIRST_PARTY_SLACK_CLIENT_SECRET?: string;
41+}
42+
43+const AIRTABLE_SCOPES = [
44+ "data.recordComments:read",
45+ "data.recordComments:write",
46+ "data.records:read",
47+ "data.records:write",
48+ "data.records:manage",
49+ "schema.bases:read",
50+ "schema.bases:write",
51+ "user.email:read",
52+ "workspacesAndBases:read",
53+ "workspacesAndBases:write",
54+ "workspacesAndBases.shares:manage",
55+ "webhook:manage",
56+] as const;
57+
58+const ATLASSIAN_SCOPES = [
59+ "read:me",
60+ "read:account",
61+ "read:jira-work",
62+ "manage:jira-project",
63+ "manage:jira-configuration",
64+ "read:jira-user",
65+ "write:jira-work",
66+ "manage:jira-webhook",
67+ "read:servicedesk-request",
68+ "manage:servicedesk-customer",
69+ "write:servicedesk-request",
70+ "write:confluence-content",
71+ "read:confluence-space.summary",
72+ "write:confluence-space",
73+ "write:confluence-file",
74+ "read:confluence-props",
75+ "write:confluence-props",
76+ "manage:confluence-configuration",
77+ "read:confluence-content.all",
78+ "read:confluence-content.summary",
79+ "search:confluence",
80+ "read:confluence-content.permission",
81+ "read:confluence-user",
82+ "read:confluence-groups",
83+ "write:confluence-groups",
84+ "offline_access",
85+] as const;
86+
87+const BOX_SCOPES = [
88+ "root_readonly",
89+ "root_readwrite",
90+ "sign_requests.readwrite",
91+ "ai.readwrite",
92+ "manage_webhook",
93+ "manage_triggers",
94+] as const;
95+
96+const GITLAB_SCOPES = [
97+ "api",
98+ "read_api",
99+ "read_user",
100+ "create_runner",
101+ "manage_runner",
102+ "k8s_proxy",
103+ "mcp",
104+ "mcp_orbit",
105+ "read_repository",
106+ "write_repository",
107+ "read_registry",
108+ "write_registry",
109+ "read_virtual_registry",
110+ "write_virtual_registry",
111+ "read_observability",
112+ "write_observability",
113+ "ai_features",
114+ "openid",
115+ "profile",
116+ "email",
117+] as const;
118+
119+const HUBSPOT_REQUIRED_SCOPES = [
120+ "oauth",
121+ "account-info.security.read",
122+ "cms.domains.read",
123+ "cms.domains.write",
124+ "crm.export",
125+ "crm.import",
126+ "crm.lists.read",
127+ "crm.lists.write",
128+ "crm.objects.companies.read",
129+ "crm.objects.companies.write",
130+ "crm.objects.contacts.read",
131+ "crm.objects.contacts.write",
132+ "crm.objects.deals.read",
133+ "crm.objects.deals.write",
134+ "crm.objects.marketing_events.read",
135+ "crm.objects.marketing_events.write",
136+ "crm.objects.owners.read",
137+ "crm.objects.quotes.read",
138+ "crm.objects.quotes.write",
139+ "crm.schemas.companies.read",
140+ "crm.schemas.companies.write",
141+ "crm.schemas.contacts.read",
142+ "crm.schemas.contacts.write",
143+ "sales-email-read",
144+ "settings.users.read",
145+ "settings.users.write",
146+ "tickets",
147+ "timeline",
148+] as const;
149+
150+const HUBSPOT_OPTIONAL_SCOPES = [
151+ "content",
152+ "crm.objects.custom.read",
153+ "crm.schemas.custom.read",
154+] as const;
155+
156+const MICROSOFT_SCOPES = [
157+ "User.Read",
158+ "Calendars.ReadWrite",
159+ "Channel.ReadBasic.All",
160+ "ChannelMessage.Read.All",
161+ "ChannelMessage.Send",
162+ "Chat.ReadWrite",
163+ "Files.ReadWrite.All",
164+ "Mail.ReadWrite",
165+ "Mail.Send",
166+ "MailboxSettings.ReadWrite",
167+ "OnlineMeetings.ReadWrite",
168+ "Sites.ReadWrite.All",
169+ "Team.ReadBasic.All",
170+ "offline_access",
171+] as const;
172+
173+// Consumer Google launch boundary. Keep this list aligned with the scopes
174+// submitted for the Executor-owned production app: ordinary Workspace services
175+// plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script,
176+// BigQuery, and Cloud Resource Manager have materially different audiences or
177+// provider requirements and remain BYO OAuth. The same scope source builds each
178+// catalog auth template, preventing picker/start drift.
179+const GOOGLE_FIRST_PARTY_PRESET_IDS = [
180+ "google-calendar",
181+ "google-meet",
182+ "google-gmail",
183+ "google-sheets",
184+ "google-drive",
185+ "google-docs",
186+ "google-slides",
187+ "google-forms",
188+ "google-tasks",
189+ "google-people",
190+ "google-photos-library",
191+ "google-photos-picker",
192+ "google-search-console",
193+] as const;
194+
195+const GOOGLE_ALLOWED_SCOPES: readonly string[] = [
196+ ...new Set([
197+ ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset),
198+ // Connections created before the full-Gmail review retain this declared
199+ // scope on reconnect. New Gmail presets request `mail.google.com`.
200+ "https://www.googleapis.com/auth/gmail.modify",
201+ ]),
202+];
203+
204+const client = (
205+ clientId: string | undefined,
206+ clientSecret: string | undefined,
207+ config: Omit<FirstPartyOAuthClientConfig, "clientId" | "clientSecret">,
208+): readonly FirstPartyOAuthClientConfig[] =>
209+ clientId && clientSecret ? [{ ...config, clientId, clientSecret }] : [];
210+
211+/** Build the enabled first-party registry from secret bindings. Provider
212+ * protocol details and scope ceilings live here so the cloud composition root
213+ * cannot drift from the registered production clients.
214+ *
215+ * Each provider-side registration must list
216+ * `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug
217+ * travels inside OAuth `state`, so the single static callback serves every
218+ * org.
219+ *
220+ * The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` /
221+ * `_TOKEN_URL` overrides exist so tests and dev instances can point the app at
222+ * an emulated provider (`@executor-js/emulate`) and run the complete flow.
223+ * Production leaves them unset. */
224+export const firstPartyOAuthClientsFor = (
225+ env: FirstPartyOAuthClientEnv,
226+): readonly FirstPartyOAuthClientConfig[] => [
227+ ...client(env.FIRST_PARTY_AIRTABLE_CLIENT_ID, env.FIRST_PARTY_AIRTABLE_CLIENT_SECRET, {
228+ name: "airtable",
229+ authorizationUrl: "https://airtable.com/oauth2/v1/authorize",
230+ tokenUrl: "https://airtable.com/oauth2/v1/token",
231+ tokenEndpointAuthMethod: "basic",
232+ authorizationScopes: AIRTABLE_SCOPES,
233+ allowedScopes: AIRTABLE_SCOPES,
234+ }),
235+ ...client(env.FIRST_PARTY_ATLASSIAN_CLIENT_ID, env.FIRST_PARTY_ATLASSIAN_CLIENT_SECRET, {
236+ name: "atlassian",
237+ authorizationUrl: "https://auth.atlassian.com/authorize",
238+ tokenUrl: "https://auth.atlassian.com/oauth/token",
239+ tokenRequestFormat: "json",
240+ authorizationScopes: ATLASSIAN_SCOPES,
241+ allowedScopes: ATLASSIAN_SCOPES,
242+ authorizationExtraParams: { audience: "api.atlassian.com", prompt: "consent" },
243+ }),
244+ ...client(env.FIRST_PARTY_BOX_CLIENT_ID, env.FIRST_PARTY_BOX_CLIENT_SECRET, {
245+ name: "box",
246+ authorizationUrl: "https://account.box.com/api/oauth2/authorize",
247+ tokenUrl: "https://api.box.com/oauth2/token",
248+ authorizationScopes: BOX_SCOPES,
249+ allowedScopes: BOX_SCOPES,
250+ }),
251+ ...client(env.FIRST_PARTY_CLICKUP_CLIENT_ID, env.FIRST_PARTY_CLICKUP_CLIENT_SECRET, {
252+ name: "clickup",
253+ authorizationUrl: "https://app.clickup.com/api",
254+ tokenUrl: "https://api.clickup.com/api/v2/oauth/token",
255+ tokenRequestFormat: "json",
256+ authorizationScopes: [],
257+ }),
258+ ...client(env.FIRST_PARTY_FIGMA_CLIENT_ID, env.FIRST_PARTY_FIGMA_CLIENT_SECRET, {
259+ name: "figma",
260+ authorizationUrl: "https://www.figma.com/oauth",
261+ tokenUrl: "https://api.figma.com/v1/oauth/token",
262+ tokenEndpointAuthMethod: "basic",
263+ integrations: [IntegrationSlug.make("figma_api")],
264+ authorizationScopes: FIGMA_SUPPORTED_OAUTH_SCOPES,
265+ allowedScopes: FIGMA_SUPPORTED_OAUTH_SCOPES,
266+ }),
267+ ...client(env.FIRST_PARTY_GITHUB_CLIENT_ID, env.FIRST_PARTY_GITHUB_CLIENT_SECRET, {
268+ name: "github",
269+ authorizationUrl:
270+ env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize",
271+ tokenUrl: env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token",
272+ integrations: [IntegrationSlug.make("github_rest")],
273+ // GitHub App user access tokens do not use classic OAuth scopes; their
274+ // capabilities come from the app's registered permissions.
275+ authorizationScopes: [],
276+ }),
277+ ...client(env.FIRST_PARTY_GITLAB_CLIENT_ID, env.FIRST_PARTY_GITLAB_CLIENT_SECRET, {
278+ name: "gitlab",
279+ authorizationUrl: "https://gitlab.com/oauth/authorize",
280+ tokenUrl: "https://gitlab.com/oauth/token",
281+ authorizationScopes: GITLAB_SCOPES,
282+ allowedScopes: GITLAB_SCOPES,
283+ }),
284+ ...client(env.FIRST_PARTY_GOOGLE_CLIENT_ID, env.FIRST_PARTY_GOOGLE_CLIENT_SECRET, {
285+ name: "google",
286+ authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
287+ tokenUrl: "https://oauth2.googleapis.com/token",
288+ allowedScopes: GOOGLE_ALLOWED_SCOPES,
289+ // Withdrawn from the connect picker: no new connection is offered the
290+ // Executor-owned Google app. The entry stays declared on purpose — every
291+ // connection already minted against it keeps refreshing and reconnecting
292+ // through it. Deleting this block, or unsetting the env vars, would strand
293+ // those connections instead.
294+ unlisted: true,
295+ }),
296+ ...client(env.FIRST_PARTY_HUBSPOT_CLIENT_ID, env.FIRST_PARTY_HUBSPOT_CLIENT_SECRET, {
297+ name: "hubspot",
298+ authorizationUrl: "https://app.hubspot.com/oauth/authorize",
299+ tokenUrl: "https://api.hubapi.com/oauth/v3/token",
300+ authorizationScopes: HUBSPOT_REQUIRED_SCOPES,
301+ allowedScopes: [...HUBSPOT_REQUIRED_SCOPES, ...HUBSPOT_OPTIONAL_SCOPES],
302+ authorizationExtraParams: { optional_scope: HUBSPOT_OPTIONAL_SCOPES.join(" ") },
303+ }),
304+ ...client(env.FIRST_PARTY_LINEAR_CLIENT_ID, env.FIRST_PARTY_LINEAR_CLIENT_SECRET, {
305+ name: "linear",
306+ authorizationUrl: "https://linear.app/oauth/authorize",
307+ tokenUrl: "https://api.linear.app/oauth/token",
308+ authorizationScopes: ["read", "write"],
309+ authorizationScopeSeparator: ",",
310+ allowedScopes: ["read", "write"],
311+ }),
312+ ...client(env.FIRST_PARTY_MICROSOFT_CLIENT_ID, env.FIRST_PARTY_MICROSOFT_CLIENT_SECRET, {
313+ name: "microsoft",
314+ authorizationUrl: MICROSOFT_AUTHORIZATION_URL,
315+ tokenUrl: MICROSOFT_TOKEN_URL,
316+ allowedScopes: MICROSOFT_SCOPES,
317+ additionalAuthorizationScopes: ["offline_access"],
318+ }),
319+ ...client(env.FIRST_PARTY_NOTION_CLIENT_ID, env.FIRST_PARTY_NOTION_CLIENT_SECRET, {
320+ name: "notion",
321+ authorizationUrl: "https://api.notion.com/v1/oauth/authorize",
322+ tokenUrl: "https://api.notion.com/v1/oauth/token",
323+ authorizationExtraParams: { owner: "user" },
324+ tokenEndpointAuthMethod: "basic",
325+ tokenRequestFormat: "json",
326+ authorizationScopes: [],
327+ }),
328+ ...client(env.FIRST_PARTY_SLACK_CLIENT_ID, env.FIRST_PARTY_SLACK_CLIENT_SECRET, {
329+ name: "slack",
330+ authorizationUrl: "https://slack.com/oauth/v2_user/authorize",
331+ tokenUrl: "https://slack.com/api/oauth.v2.user.access",
332+ resource: "https://mcp.slack.com",
333+ integrations: [IntegrationSlug.make("slack")],
334+ allowedScopes: slackMcpUserScopes,
335+ }),
336+];
apps/cloud/src/env-augment.d.ts+20−0
@@ -72,6 +72,16 @@ declare global { · 71 unmodified lines
7272 // unset pair simply ships no first-party app for that provider. The
7373 // registered callback on the provider side must be
7474 // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`.
75+ FIRST_PARTY_AIRTABLE_CLIENT_ID?: string;
76+ FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string;
77+ FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string;
78+ FIRST_PARTY_ATLASSIAN_CLIENT_SECRET?: string;
79+ FIRST_PARTY_BOX_CLIENT_ID?: string;
80+ FIRST_PARTY_BOX_CLIENT_SECRET?: string;
81+ FIRST_PARTY_CLICKUP_CLIENT_ID?: string;
82+ FIRST_PARTY_CLICKUP_CLIENT_SECRET?: string;
83+ FIRST_PARTY_FIGMA_CLIENT_ID?: string;
84+ FIRST_PARTY_FIGMA_CLIENT_SECRET?: string;
7585 FIRST_PARTY_GITHUB_CLIENT_ID?: string;
7686 FIRST_PARTY_GITHUB_CLIENT_SECRET?: string;
7787 // Endpoint overrides for the GitHub first-party app, so tests/dev can
@@ -79,8 +89,18 @@ declare global { · 1 unmodified line
7989 // production (the real github.com endpoints are the defaults).
8090 FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string;
8191 FIRST_PARTY_GITHUB_TOKEN_URL?: string;
92+ FIRST_PARTY_GITLAB_CLIENT_ID?: string;
93+ FIRST_PARTY_GITLAB_CLIENT_SECRET?: string;
8294 FIRST_PARTY_GOOGLE_CLIENT_ID?: string;
8395 FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string;
96+ FIRST_PARTY_HUBSPOT_CLIENT_ID?: string;
97+ FIRST_PARTY_HUBSPOT_CLIENT_SECRET?: string;
98+ FIRST_PARTY_LINEAR_CLIENT_ID?: string;
99+ FIRST_PARTY_LINEAR_CLIENT_SECRET?: string;
100+ FIRST_PARTY_MICROSOFT_CLIENT_ID?: string;
101+ FIRST_PARTY_MICROSOFT_CLIENT_SECRET?: string;
102+ FIRST_PARTY_NOTION_CLIENT_ID?: string;
103+ FIRST_PARTY_NOTION_CLIENT_SECRET?: string;
84104 FIRST_PARTY_SLACK_CLIENT_ID?: string;
85105 FIRST_PARTY_SLACK_CLIENT_SECRET?: string;
86106
packages/core/sdk/src/executor.ts+11−1
@@ -2000,6 +2000,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1999 unmodified lines
20002000 readonly tokenUrl: string;
20012001 readonly grant: string;
20022002 readonly resource: string | null;
2003+ readonly tokenEndpointAuthMethod?: "body" | "basic";
2004+ readonly tokenRequestFormat?: "form" | "json";
20032005 }
20042006
20052007 /** What drove a refresh: the pre-call expiry check (`proactive`), or an
@@ -2307,7 +2309,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 301 unmodified lines
23072309 clientSecret: firstParty.clientSecret,
23082310 tokenUrl: firstParty.tokenUrl,
23092311 grant: "authorization_code",
2310− resource: null,
2312+ resource: firstParty.resource ?? null,
2313+ ...(firstParty.tokenEndpointAuthMethod === undefined
2314+ ? {}
2315+ : { tokenEndpointAuthMethod: firstParty.tokenEndpointAuthMethod }),
2316+ ...(firstParty.tokenRequestFormat === undefined
2317+ ? {}
2318+ : { tokenRequestFormat: firstParty.tokenRequestFormat }),
23112319 } satisfies RefreshClient;
23122320 }
23132321 const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;
@@ -2439,6 +2447,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 125 unmodified lines
24392447 // RFC 8707: keep the re-minted token bound to the same resource
24402448 // (MCP servers require this on refresh).
24412449 resource: clientRow.resource ?? undefined,
2450+ clientAuth: clientRow.tokenEndpointAuthMethod,
2451+ requestFormat: clientRow.tokenRequestFormat,
24422452 endpointUrlPolicy: config.oauthEndpointUrlPolicy,
24432453 fetch: config.fetch,
24442454 }).pipe(
packages/core/sdk/src/oauth-client.ts+19−0
@@ -187,6 +187,25 @@ export interface FirstPartyOAuthClientConfig { · 186 unmodified lines
187187 * GitHub Apps, whose capabilities are configured on the app and whose OAuth
188188 * user-token flow does not use scopes. Omit for normal OAuth clients. */
189189 readonly authorizationScopes?: readonly string[];
190+ /** Scopes the host always adds to the integration-declared set. Use this for
191+ * provider lifecycle scopes such as Atlassian and Microsoft
192+ * `offline_access`; unlike `authorizationScopes`, this preserves the
193+ * integration's least-privilege scopes. */
194+ readonly additionalAuthorizationScopes?: readonly string[];
195+ /** Separator used in the authorize URL's `scope` parameter. OAuth defaults
196+ * to a space; legacy providers such as Linear require a comma. */
197+ readonly authorizationScopeSeparator?: string;
198+ /** Provider-specific, non-secret authorize parameters fixed by the host
199+ * registration contract (for example Atlassian's `audience`). These are
200+ * merged after generic provider defaults and may intentionally override
201+ * them. */
202+ readonly authorizationExtraParams?: Readonly<Record<string, string>>;
203+ /** Token endpoint client-auth transport. Omitted means
204+ * `client_secret_post`; `basic` sends the secret only in HTTP Basic auth. */
205+ readonly tokenEndpointAuthMethod?: "body" | "basic";
206+ /** Token endpoint request encoding. OAuth defaults to URL-encoded form;
207+ * providers such as Atlassian, ClickUp, and Notion require JSON. */
208+ readonly tokenRequestFormat?: "form" | "json";
190209 /** Withdraw the app from every listing surface without retiring it. It stops
191210 * appearing in `listClients` — so connect pickers and the agent-facing
192211 * client list never offer it — while remaining fully resolvable by slug.
packages/core/sdk/src/oauth-first-party.test.ts+36−0
@@ -199,6 +199,42 @@ describe("first-party oauth clients", () => { · 198 unmodified lines
199199 ),
200200 );
201201
202+ it.effect("applies first-party lifecycle scopes, separators, and authorize parameters", () =>
203+ Effect.scoped(
204+ Effect.gen(function* () {
205+ const server = yield* serveOAuthTestServer({ scopes: ["read", "offline_access"] });
206+ const { executor } = yield* makeTestWorkspaceHarness({
207+ plugins,
208+ firstPartyOAuthClients: [
209+ {
210+ ...firstPartyClientFor(server),
211+ allowedScopes: ["read", "offline_access"],
212+ additionalAuthorizationScopes: ["offline_access"],
213+ authorizationScopeSeparator: ",",
214+ authorizationExtraParams: { audience: "api.example.com", prompt: "consent" },
215+ },
216+ ],
217+ });
218+ yield* executor.acme.seed(["read"]);
219+
220+ const started = yield* executor.oauth.start({
221+ owner: "org",
222+ client: FIRST_PARTY,
223+ clientOwner: "org",
224+ name: ConnectionName.make("lifecycle"),
225+ integration: INTEG,
226+ template: TEMPLATE,
227+ });
228+ expect(started.status).toBe("redirect");
229+ if (started.status !== "redirect") return;
230+ const authorizationUrl = new URL(started.authorizationUrl);
231+ expect(authorizationUrl.searchParams.get("scope")).toBe("read,offline_access");
232+ expect(authorizationUrl.searchParams.get("audience")).toBe("api.example.com");
233+ expect(authorizationUrl.searchParams.get("prompt")).toBe("consent");
234+ }),
235+ ),
236+ );
237+
202238 it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () =>
203239 Effect.scoped(
204240 Effect.gen(function* () {
packages/core/sdk/src/oauth-helpers.test.ts+183−1
@@ -6,7 +6,8 @@ · 5 unmodified lines
66 // ---------------------------------------------------------------------------
77
88 import { describe, expect, it } from "@effect/vitest";
9−import { Cause, Effect, Exit, Ref } from "effect";
9+import { Cause, Effect, Exit, Ref, Schema } from "effect";
10+import type * as Tracer from "effect/Tracer";
1011 import { HttpServerResponse } from "effect/unstable/http";
1112
1213 import {
@@ -34,8 +35,11 @@ interface TokenCall { · 21 unmodified lines
3435 readonly url: string;
3536 readonly headers: Readonly<Record<string, string>>;
3637 readonly body: URLSearchParams;
38+ readonly jsonBody: unknown;
3739 }
3840
41+const decodeJsonBody = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
42+
3943 type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse;
4044
4145 const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse =>
@@ -52,6 +56,9 @@ const serveTokenEndpoint = (handler: TokenHandler) => · 10 unmodified lines
5256 url: request.url ?? "/",
5357 headers: request.headers,
5458 body: new URLSearchParams(bodyText),
59+ jsonBody: request.headers["content-type"]?.startsWith("application/json")
60+ ? decodeJsonBody(bodyText)
61+ : null,
5562 };
5663 yield* Ref.update(calls, (all) => [...all, call]);
5764 return handler(call);
@@ -103,6 +110,45 @@ const tokenResponse = · 45 unmodified lines
103110 () =>
104111 json(200, body);
105112
113+/** Records each span's attributes and its ending exit, so a test can assert on
114+ * exactly what telemetry would export for the token-request span. */
115+interface RecordedSpan {
116+ readonly attributes: Map<string, unknown>;
117+ endExit?: unknown;
118+}
119+
120+const makeRecordingTracer = (spans: Map<string, RecordedSpan>): Tracer.Tracer => ({
121+ span: (options) => {
122+ const record: RecordedSpan = { attributes: new Map() };
123+ spans.set(options.name, record);
124+ let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };
125+ return {
126+ _tag: "Span",
127+ name: options.name,
128+ spanId: "0000000000000001",
129+ traceId: "00000000000000000000000000000001",
130+ parent: options.parent,
131+ annotations: options.annotations,
132+ get status() {
133+ return status;
134+ },
135+ attributes: record.attributes,
136+ links: options.links,
137+ sampled: options.sampled,
138+ kind: options.kind,
139+ end: (endTime, exit) => {
140+ record.endExit = exit;
141+ status = { _tag: "Ended", startTime: options.startTime, endTime, exit };
142+ },
143+ attribute: (key, value) => {
144+ record.attributes.set(key, value);
145+ },
146+ event: () => undefined,
147+ addLinks: () => undefined,
148+ };
149+ },
150+});
151+
106152 const tokenResponseFetch =
107153 (body: unknown): typeof globalThis.fetch =>
108154 async () =>
@@ -260,6 +306,56 @@ describe("buildAuthorizationUrl", () => { · 151 unmodified lines
260306 });
261307
262308 describe("exchangeAuthorizationCode", () => {
309+ it.effect("supports JSON token exchange with HTTP Basic client authentication", () =>
310+ withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
311+ Effect.gen(function* () {
312+ yield* exchangeAuthorizationCode({
313+ tokenUrl,
314+ clientId: "cid",
315+ clientSecret: "csecret",
316+ redirectUrl: "https://app.example.com/cb",
317+ codeVerifier: "verifier",
318+ code: "abc",
319+ clientAuth: "basic",
320+ requestFormat: "json",
321+ });
322+ const call = (yield* calls)[0]!;
323+ expect(call.headers["content-type"]).toBe("application/json");
324+ expect(call.headers["authorization"]).toBe("Basic Y2lkOmNzZWNyZXQ=");
325+ expect(call.jsonBody).toEqual({
326+ grant_type: "authorization_code",
327+ code: "abc",
328+ redirect_uri: "https://app.example.com/cb",
329+ code_verifier: "verifier",
330+ });
331+ }),
332+ ),
333+ );
334+
335+ it.effect("supports JSON token exchange with client credentials in the body", () =>
336+ withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
337+ Effect.gen(function* () {
338+ yield* exchangeAuthorizationCode({
339+ tokenUrl,
340+ clientId: "cid",
341+ clientSecret: "csecret",
342+ redirectUrl: "https://app.example.com/cb",
343+ codeVerifier: "verifier",
344+ code: "abc",
345+ requestFormat: "json",
346+ });
347+ expect((yield* calls)[0]!.jsonBody).toEqual({
348+ grant_type: "authorization_code",
349+ code: "abc",
350+ redirect_uri: "https://app.example.com/cb",
351+ code_verifier: "verifier",
352+ client_id: "cid",
353+ client_secret: "csecret",
354+ });
355+ }),
356+ ),
357+ );
358+
263359 it.effect("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", () =>
264360 withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
265361 Effect.gen(function* () {
@@ -765,6 +861,9 @@ describe("exchangeAuthorizationCode", () => { · 499 unmodified lines
765861 }),
766862 );
767863
864+ // Non-secret description text must keep propagating even from a confidential
865+ // client: the scrub below removes ONLY the exact submitted secret, never the
866+ // AS's verdict prose around it.
768867 it.effect("propagates RFC 6749 error_description text in the OAuth2Error", () =>
769868 withTokenEndpoint(
770869 () =>
@@ -778,6 +877,7 @@ describe("exchangeAuthorizationCode", () => { · 7 unmodified lines
778877 exchangeAuthorizationCode({
779878 tokenUrl,
780879 clientId: "cid",
880+ clientSecret: "csecret",
781881 redirectUrl: "https://cb",
782882 codeVerifier: "v",
783883 code: "c",
@@ -786,10 +886,67 @@ describe("exchangeAuthorizationCode", () => { · 2 unmodified lines
786886 expect(Exit.isFailure(exit)).toBe(true);
787887 if (!Exit.isFailure(exit)) return;
788888 expect(JSON.stringify(exit.cause)).toContain("Code expired");
889+ const failure = Cause.squash(exit.cause) as OAuth2Error;
890+ expect(failure.error).toBe("invalid_grant");
789891 }),
790892 ),
791893 );
792894
895+ // The review's canary for the first-party secret leak. A provider that
896+ // echoes the submitted client_secret inside error_description would carry a
897+ // DEPLOYMENT-WIDE credential into the OAuth2Error message — and from there
898+ // into OAuthCompleteError, the popup's browser-visible errorDetails, and the
899+ // token-request span. Assert on the WHOLE rendered failure, not just the
900+ // message: what a log line or a `JSON.stringify` prints includes the retained
901+ // rejection, so an echo surviving anywhere in the cause is still a leak.
902+ it.effect("scrubs a client secret echoed in error_description from the whole failure", () => {
903+ const spans = new Map<string, RecordedSpan>();
904+ return withTokenEndpoint(
905+ () =>
906+ json(400, {
907+ error: "invalid_client",
908+ error_description:
909+ "authentication failed for secret SECRET-CANARY-must-not-escape, check your credentials",
910+ }),
911+ ({ tokenUrl }) =>
912+ Effect.gen(function* () {
913+ const exit = yield* Effect.exit(
914+ exchangeAuthorizationCode({
915+ tokenUrl,
916+ clientId: "cid",
917+ clientSecret: "SECRET-CANARY-must-not-escape",
918+ redirectUrl: "https://cb",
919+ codeVerifier: "v",
920+ code: "c",
921+ }),
922+ );
923+ expect(Exit.isFailure(exit)).toBe(true);
924+ if (!Exit.isFailure(exit)) return;
925+ for (const rendered of [Cause.pretty(exit.cause), JSON.stringify(exit.cause)]) {
926+ expect(rendered).not.toContain("SECRET-CANARY-must-not-escape");
927+ }
928+ const failure = Cause.squash(exit.cause) as OAuth2Error;
929+ expect(failure).toBeInstanceOf(OAuth2Error);
930+ // Masked, not dropped: the verdict prose around the secret survives,
931+ // and so does everything classification and the span read.
932+ expect(failure.message).toContain("[redacted]");
933+ expect(failure.message).toContain("check your credentials");
934+ expect(failure.message).not.toContain("SECRET-CANARY-must-not-escape");
935+ expect(failure.error).toBe("invalid_client");
936+ expect(failure.status).toBe(400);
937+ // The token-request span — attributes AND ending exit — is what the
938+ // exporter sees; the echo must not survive there either.
939+ const tokenSpan = spans.get("executor.oauth.token_request");
940+ expect(tokenSpan?.attributes.get("executor.oauth.error_code")).toBe("invalid_client");
941+ const spanRendered = JSON.stringify({
942+ attributes: [...(tokenSpan?.attributes ?? [])],
943+ exit: tokenSpan?.endExit,
944+ });
945+ expect(spanRendered).not.toContain("SECRET-CANARY-must-not-escape");
946+ }),
947+ ).pipe(Effect.withTracer(makeRecordingTracer(spans)));
948+ });
949+
793950 it.effect("includes HTTP status and body preview for non-OAuth token endpoint errors", () =>
794951 withTokenEndpoint(
795952 () => HttpServerResponse.text("route not found", { status: 404 }),
@@ -1192,6 +1349,31 @@ describe("exchangeClientCredentials", () => { · 396 unmodified lines
11921349 });
11931350
11941351 describe("refreshAccessToken", () => {
1352+ it.effect("persists provider-compatible JSON refresh rotation requests", () =>
1353+ withTokenEndpoint(
1354+ tokenResponse({ ...validRefreshBody, refresh_token: "rotated" }),
1355+ ({ tokenUrl, calls }) =>
1356+ Effect.gen(function* () {
1357+ const result = yield* refreshAccessToken({
1358+ tokenUrl,
1359+ clientId: "cid",
1360+ clientSecret: "csecret",
1361+ refreshToken: "old",
1362+ scopes: ["read", "offline_access"],
1363+ requestFormat: "json",
1364+ });
1365+ expect(result.refresh_token).toBe("rotated");
1366+ expect((yield* calls)[0]!.jsonBody).toEqual({
1367+ grant_type: "refresh_token",
1368+ refresh_token: "old",
1369+ scope: "read offline_access",
1370+ client_id: "cid",
1371+ client_secret: "csecret",
1372+ });
1373+ }),
1374+ ),
1375+ );
1376+
11951377 it.effect("normalizes Slack's comma-delimited scopes on refresh", () =>
11961378 Effect.gen(function* () {
11971379 const result = yield* refreshAccessToken({
packages/core/sdk/src/oauth-helpers.ts+169−33
@@ -641,8 +641,23 @@ const oauthErrorFromResponseBody = ( · 640 unmodified lines
641641 });
642642 };
643643
644−const toOAuth2Error = (cause: unknown): OAuth2Error => {
644+/** Exact-string scrub of the submitted client secret from text bound for an
645+ * `OAuth2Error`. A misbehaving authorization server can echo the credential it
646+ * was just sent back inside `error_description` — a field that is deliberately
647+ * previewable everywhere else in this module because it is the AS's
648+ * human-readable verdict. The message it lands in reaches the OAuth popup's
649+ * browser-visible error details, persisted connection health, and telemetry,
650+ * and for a first-party client the secret is deployment-wide. The helper knows
651+ * exactly which string it submitted, so this is a targeted replacement of that
652+ * one value, not a heuristic. */
653+const redactSubmittedClientSecret = (
654+ text: string,
655+ clientSecret: string | null | undefined,
656+): string => (clientSecret ? text.replaceAll(clientSecret, "[redacted]") : text);
657+
658+const toOAuth2Error = (cause: unknown, clientSecret?: string | null): OAuth2Error => {
645659 if (isOAuth2Error(cause)) return cause;
660+ const scrub = (text: string): string => redactSubmittedClientSecret(text, clientSecret);
646661 if (typeof cause === "object" && cause !== null) {
647662 const c = cause as {
648663 error?: unknown;
@@ -657,8 +672,10 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 8 unmodified lines
657672 ? c.message
658673 : undefined;
659674 return new OAuth2Error({
660− message: `OAuth token exchange failed: ${description ?? code ?? "unknown error"}`,
661− error: code,
675+ message: scrub(`OAuth token exchange failed: ${description ?? code ?? "unknown error"}`),
676+ // The code is scrubbed too: a conform envelope's `error` is an arbitrary
677+ // string the AS chose, and it flows into the token-request span attribute.
678+ error: code === undefined ? undefined : scrub(code),
662679 cause,
663680 });
664681 }
@@ -674,13 +691,18 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 9 unmodified lines
674691 *
675692 * `fallbackMessage` is for the paths that hold the error Response directly
676693 * rather than catching a thrown oauth4webapi error: there is no library
677− * message to build on, so the caller names the step instead. */
694+ * message to build on, so the caller names the step instead.
695+ *
696+ * `clientSecret` is the secret the failed request submitted, when the client
697+ * is confidential. Every constructed message is scrubbed of it, because the
698+ * body it is built from is the AS's and may echo the credential back. */
678699 const toOAuth2ErrorWithHttpSummary = (
679700 cause: unknown,
680− options?: { readonly fallbackMessage?: string },
701+ options?: { readonly fallbackMessage?: string; readonly clientSecret?: string | null },
681702 ): Effect.Effect<OAuth2Error> => {
682703 if (isOAuth2Error(cause)) return Effect.succeed(cause);
683− const base = toOAuth2Error(cause);
704+ const scrub = (text: string): string => redactSubmittedClientSecret(text, options?.clientSecret);
705+ const base = toOAuth2Error(cause, options?.clientSecret);
684706 const response = responseFromOAuthErrorCause(cause);
685707 if (!response) {
686708 // No Response, but possibly a body the library already parsed off one it
@@ -704,8 +726,8 @@ const toOAuth2ErrorWithHttpSummary = ( · 17 unmodified lines
704726 // exactly the leak the message allowlist exists to prevent. The other
705727 // branches keep their cause because theirs is diagnostic, not a body.
706728 new OAuth2Error({
707− message: `${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`,
708− error: base.error ?? envelope?.error,
729+ message: scrub(`${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`),
730+ error: base.error ?? (envelope?.error === undefined ? undefined : scrub(envelope.error)),
709731 status: PARSED_BODY_CAUSE_STATUS,
710732 }),
711733 );
@@ -727,20 +749,37 @@ const toOAuth2ErrorWithHttpSummary = ( · 15 unmodified lines
727749 : `${headline}: ${recovered.code}${
728750 recovered.description === undefined ? "" : ` — ${recovered.description}`
729751 }`;
752+ const code = base.error ?? recovered?.code;
753+ // The diagnostic rejection is dropped when the submitted secret is visible
754+ // in its serialised form — oauth4webapi's ResponseBodyError carries the
755+ // AS's `error_description` as an own enumerable field, so anything that
756+ // renders the WHOLE failure (`Cause.pretty`, `JSON.stringify`) would
757+ // replay the echo straight past the message scrub above. Everything
758+ // classification reads — status, code, HTTP summary — is already lifted
759+ // out, mirroring the malformed-200 branch's treatment of token-bearing
760+ // rejections; the ordinary no-echo failure keeps its cause untouched.
761+ const causeEchoesSecret =
762+ Boolean(options?.clientSecret) && safeStringify(cause).includes(options?.clientSecret ?? "");
730763 return new OAuth2Error({
731− message: `${described} (${summary})`,
732− error: base.error ?? recovered?.code,
764+ message: scrub(`${described} (${summary})`),
765+ error: code === undefined ? undefined : scrub(code),
733766 // Carried even when no code was recovered: the status is what tells a
734767 // caller whether the AS refused (4xx — permanent, stop) or stumbled (5xx
735768 // — retry). Most real refusals arrive with no code at all.
736769 status: response.status,
737− cause,
770+ ...(causeEchoesSecret ? {} : { cause }),
738771 });
739772 });
740773 };
741774
742−const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2Error> =>
743− toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error)));
775+/** Curried on the submitted client secret so every `Effect.catch` site names
776+ * the credential its request carried — the scrub cannot work without it. */
777+const failOAuth2WithHttpSummary =
778+ (clientSecret: string | null | undefined) =>
779+ (cause: unknown): Effect.Effect<never, OAuth2Error> =>
780+ toOAuth2ErrorWithHttpSummary(cause, { clientSecret }).pipe(
781+ Effect.flatMap((error) => Effect.fail(error)),
782+ );
744783
745784 /** Fail from a token-endpoint error Response the caller holds directly — the
746785 * `genericTokenEndpointRequest` paths, where oauth4webapi hands back the raw
@@ -751,8 +790,9 @@ const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2E · 4 unmodified lines
751790 const failOAuth2FromErrorResponse = (
752791 response: Response,
753792 fallbackMessage: string,
793+ clientSecret: string | null | undefined,
754794 ): Effect.Effect<never, OAuth2Error> =>
755− toOAuth2ErrorWithHttpSummary(response, { fallbackMessage }).pipe(
795+ toOAuth2ErrorWithHttpSummary(response, { fallbackMessage, clientSecret }).pipe(
756796 Effect.flatMap((error) => Effect.fail(error)),
757797 );
758798
@@ -1069,6 +1109,9 @@ export type ExchangeAuthorizationCodeInput = { · 310 unmodified lines
10691109 readonly codeVerifier: string;
10701110 readonly code: string;
10711111 readonly clientAuth?: ClientAuthMethod;
1112+ /** Encoding required by the provider's token endpoint. OAuth defaults to
1113+ * URL-encoded form; a small set of providers require a JSON object. */
1114+ readonly requestFormat?: "form" | "json";
10721115 readonly idTokenSigningAlgValuesSupported?: readonly string[];
10731116 /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on
10741117 * the token request when the client knows the resource it intends
@@ -1079,6 +1122,59 @@ export type ExchangeAuthorizationCodeInput = { · 4 unmodified lines
10791122 readonly fetch?: typeof globalThis.fetch;
10801123 };
10811124
1125+const base64BasicCredentials = (clientId: string, clientSecret: string): string => {
1126+ const bytes = new TextEncoder().encode(`${clientId}:${clientSecret}`);
1127+ let binary = "";
1128+ for (const byte of bytes) binary += String.fromCharCode(byte);
1129+ return globalThis.btoa(binary);
1130+};
1131+
1132+const jsonTokenEndpointRequest = async (input: {
1133+ readonly tokenUrl: string;
1134+ readonly clientId: string;
1135+ readonly clientSecret?: string | null;
1136+ readonly clientAuth: ClientAuthMethod;
1137+ readonly grantType: "authorization_code" | "refresh_token";
1138+ readonly parameters: Readonly<Record<string, string>>;
1139+ readonly timeoutMs?: number;
1140+ readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
1141+ readonly fetch?: typeof globalThis.fetch;
1142+}): Promise<Response> => {
1143+ const tokenUrl = assertSupportedOAuthEndpointUrl(
1144+ input.tokenUrl,
1145+ "Token URL",
1146+ input.endpointUrlPolicy,
1147+ );
1148+ const headers = new Headers({
1149+ accept: "application/json",
1150+ "content-type": "application/json",
1151+ });
1152+ const confidential = Boolean(input.clientSecret);
1153+ if (confidential && input.clientAuth === "basic") {
1154+ headers.set(
1155+ "authorization",
1156+ `Basic ${base64BasicCredentials(input.clientId, input.clientSecret ?? "")}`,
1157+ );
1158+ }
1159+ const body = {
1160+ grant_type: input.grantType,
1161+ ...input.parameters,
1162+ ...(confidential && input.clientAuth === "basic"
1163+ ? {}
1164+ : {
1165+ client_id: input.clientId,
1166+ ...(confidential ? { client_secret: input.clientSecret ?? "" } : {}),
1167+ }),
1168+ };
1169+ // oxlint-disable-next-line executor/no-raw-fetch -- boundary: provider token exchange is the SDK's HTTP boundary and preserves its injected fetch seam
1170+ return await (input.fetch ?? globalThis.fetch)(tokenUrl, {
1171+ method: "POST",
1172+ headers,
1173+ body: JSON.stringify(body),
1174+ signal: AbortSignal.timeout(input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS),
1175+ });
1176+};
1177+
10821178 export const exchangeAuthorizationCode = (
10831179 input: ExchangeAuthorizationCodeInput,
10841180 ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
@@ -1106,24 +1202,37 @@ export const exchangeAuthorizationCode = ( · 21 unmodified lines
11061202 if (input.resource) {
11071203 params.set("resource", input.resource);
11081204 }
1109− const response = await oauth.genericTokenEndpointRequest(
1110− as,
1111− client,
1112− clientAuth,
1113− "authorization_code",
1114− params,
1115− oauth4webapiRequestOptions(
1116− input.tokenUrl,
1117− input.timeoutMs,
1118− input.endpointUrlPolicy,
1119− input.fetch,
1120− ),
1121− );
1205+ const response =
1206+ input.requestFormat === "json"
1207+ ? await jsonTokenEndpointRequest({
1208+ tokenUrl: input.tokenUrl,
1209+ clientId: input.clientId,
1210+ clientSecret: input.clientSecret,
1211+ clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,
1212+ grantType: "authorization_code",
1213+ parameters: Object.fromEntries(params),
1214+ timeoutMs: input.timeoutMs,
1215+ endpointUrlPolicy: input.endpointUrlPolicy,
1216+ fetch: input.fetch,
1217+ })
1218+ : await oauth.genericTokenEndpointRequest(
1219+ as,
1220+ client,
1221+ clientAuth,
1222+ "authorization_code",
1223+ params,
1224+ oauth4webapiRequestOptions(
1225+ input.tokenUrl,
1226+ input.timeoutMs,
1227+ input.endpointUrlPolicy,
1228+ input.fetch,
1229+ ),
1230+ );
11221231 return await processTokenEndpointResponse(as, client, response);
11231232 },
11241233 catch: (cause) => cause,
11251234 }).pipe(
1126− Effect.catch(failOAuth2WithHttpSummary),
1235+ Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
11271236 withTokenRequestSpan({
11281237 grantType: "authorization_code",
11291238 tokenUrl: input.tokenUrl,
@@ -1186,7 +1295,7 @@ export const exchangeClientCredentials = ( · 56 unmodified lines
11861295 },
11871296 catch: (cause) => cause,
11881297 }).pipe(
1189− Effect.catch(failOAuth2WithHttpSummary),
1298+ Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
11901299 withTokenRequestSpan({
11911300 grantType: "client_credentials",
11921301 tokenUrl: input.tokenUrl,
@@ -1208,6 +1317,9 @@ export type RefreshAccessTokenInput = { · 15 unmodified lines
12081317 readonly scopes?: readonly string[];
12091318 readonly scopeSeparator?: string;
12101319 readonly clientAuth?: ClientAuthMethod;
1320+ /** Encoding required by the provider's token endpoint. OAuth defaults to
1321+ * URL-encoded form; a small set of providers require a JSON object. */
1322+ readonly requestFormat?: "form" | "json";
12111323 readonly idTokenSigningAlgValuesSupported?: readonly string[];
12121324 /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on
12131325 * refresh requests so the new access token's audience is bound to
@@ -1241,6 +1353,26 @@ export const refreshAccessToken = ( · 27 unmodified lines
12411353 }
12421354 const additionalParameters =
12431355 Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;
1356+ if (input.requestFormat === "json") {
1357+ const response = await jsonTokenEndpointRequest({
1358+ tokenUrl: input.tokenUrl,
1359+ clientId: input.clientId,
1360+ clientSecret: input.clientSecret,
1361+ clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,
1362+ grantType: "refresh_token",
1363+ parameters: {
1364+ refresh_token: input.refreshToken,
1365+ ...(input.scopes && input.scopes.length > 0
1366+ ? { scope: input.scopes.join(input.scopeSeparator ?? " ") }
1367+ : {}),
1368+ ...(input.resource ? { resource: input.resource } : {}),
1369+ },
1370+ timeoutMs: input.timeoutMs,
1371+ endpointUrlPolicy: input.endpointUrlPolicy,
1372+ fetch: input.fetch,
1373+ });
1374+ return await processTokenEndpointResponse(as, client, response);
1375+ }
12441376 const response = await oauth.refreshTokenGrantRequest(
12451377 as,
12461378 client,
@@ -1265,7 +1397,7 @@ export const refreshAccessToken = ( · 18 unmodified lines
12651397 },
12661398 catch: (cause) => cause,
12671399 }).pipe(
1268− Effect.catch(failOAuth2WithHttpSummary),
1400+ Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
12691401 withTokenRequestSpan({
12701402 grantType: "refresh_token",
12711403 tokenUrl: input.tokenUrl,
@@ -1375,10 +1507,14 @@ export const exchangeSubjectTokenForIdJag = ( · 103 unmodified lines
13751507 );
13761508 },
13771509 catch: (cause) => cause,
1378− }).pipe(Effect.catch(failOAuth2WithHttpSummary));
1510+ }).pipe(Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)));
13791511
13801512 if (!response.ok) {
1381− return yield* failOAuth2FromErrorResponse(response, "ID-JAG token exchange was rejected");
1513+ return yield* failOAuth2FromErrorResponse(
1514+ response,
1515+ "ID-JAG token exchange was rejected",
1516+ input.clientSecret,
1517+ );
13821518 }
13831519
13841520 // Nothing else reads this body, so it is consumed directly. A read failure
@@ -1485,7 +1621,7 @@ export const redeemIdJagAssertion = ( · 100 unmodified lines
14851621 },
14861622 catch: (cause) => cause,
14871623 }).pipe(
1488− Effect.catch(failOAuth2WithHttpSummary),
1624+ Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
14891625 withTokenRequestSpan({
14901626 grantType: JWT_BEARER_GRANT_TYPE,
14911627 tokenUrl: input.tokenUrl,
packages/core/sdk/src/oauth-service.ts+23−3
@@ -509,6 +509,8 @@ interface LoadedOAuthClient { · 508 unmodified lines
509509 /** Resolved literal secret (read from the provider via the stored item id). */
510510 readonly clientSecret: string;
511511 readonly resource: string | null;
512+ readonly tokenEndpointAuthMethod?: "body" | "basic";
513+ readonly tokenRequestFormat?: "form" | "json";
512514 }
513515
514516 /** Where an OAuth app's client secret is stored in the default writable
@@ -611,6 +613,8 @@ export const loadedFirstPartyClient = ( · 96 unmodified lines
611613 readonly clientId: string;
612614 readonly clientSecret: string;
613615 readonly resource: string | null;
616+ readonly tokenEndpointAuthMethod?: "body" | "basic";
617+ readonly tokenRequestFormat?: "form" | "json";
614618 } => ({
615619 slug: String(firstPartyOAuthClientSlug(config.name)),
616620 authorizationUrl: config.authorizationUrl,
@@ -619,6 +623,12 @@ export const loadedFirstPartyClient = ( · 2 unmodified lines
619623 clientId: config.clientId,
620624 clientSecret: config.clientSecret,
621625 resource: config.resource ?? null,
626+ ...(config.tokenEndpointAuthMethod === undefined
627+ ? {}
628+ : { tokenEndpointAuthMethod: config.tokenEndpointAuthMethod }),
629+ ...(config.tokenRequestFormat === undefined
630+ ? {}
631+ : { tokenRequestFormat: config.tokenRequestFormat }),
622632 });
623633
624634 export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
@@ -1683,6 +1693,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1058 unmodified lines
16831693 : scopePolicy.kind === "discover"
16841694 ? requestedScopes
16851695 : yield* filterAuthorizationCodeScopes(client, requestedScopes);
1696+ const completeAuthorizationScopes = dedupeScopes([
1697+ ...authorizationRequestedScopes,
1698+ ...(firstParty?.additionalAuthorizationScopes ?? []),
1699+ ]);
16861700
16871701 // authorization_code: persist a session + build the authorize URL.
16881702 const verifier = createPkceCodeVerifier();
@@ -1744,7 +1758,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines
17441758 payload: {
17451759 owner: input.owner,
17461760 clientOwner: input.clientOwner,
1747− requestedScopes: authorizationRequestedScopes,
1761+ requestedScopes: completeAuthorizationScopes,
17481762 },
17491763 expires_at: expiresAt,
17501764 created_at: now,
@@ -1757,14 +1771,18 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 6 unmodified lines
17571771 authorizationUrl: client.authorizationUrl,
17581772 clientId: client.clientId,
17591773 redirectUrl: flowRedirectUri,
1760− scopes: authorizationRequestedScopes,
1774+ scopes: completeAuthorizationScopes,
17611775 state: providerState,
17621776 codeChallenge: challenge,
1777+ scopeSeparator: firstParty?.authorizationScopeSeparator,
17631778 resource: client.resource ?? undefined,
17641779 // Provider quirks (Google: access_type=offline + prompt=consent) —
17651780 // without these Google returns no refresh token and won't re-consent
17661781 // to widen scopes on reconnect.
1767− extraParams: providerAuthorizeExtras(client.authorizationUrl),
1782+ extraParams: {
1783+ ...providerAuthorizeExtras(client.authorizationUrl),
1784+ ...(firstParty?.authorizationExtraParams ?? {}),
1785+ },
17681786 endpointUrlPolicy: deps.endpointUrlPolicy,
17691787 }),
17701788 catch: (cause) =>
@@ -1883,6 +1901,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 112 unmodified lines
18831901 redirectUrl: session.redirectUrl,
18841902 codeVerifier: session.pkceVerifier,
18851903 code: input.code,
1904+ clientAuth: client.tokenEndpointAuthMethod,
1905+ requestFormat: client.tokenRequestFormat,
18861906 resource: client.resource ?? undefined,
18871907 endpointUrlPolicy: deps.endpointUrlPolicy,
18881908 fetch,
packages/plugins/openapi/src/sdk/presets.ts+2−2
@@ -40,10 +40,10 @@ export const FIGMA_SUPPORTED_OAUTH_SCOPES = [ · 39 unmodified lines
4040 "file_dev_resources:write",
4141 "file_metadata:read",
4242 "file_versions:read",
43+ "folder_metadata:read",
44+ "folders:read",
4345 "library_assets:read",
4446 "library_content:read",
45− "project_metadata:read",
46− "projects:read",
4747 "team_library_content:read",
4848 "webhooks:read",
4949 "webhooks:write",