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
42 PluginsProvider,42 PluginsProvider,
43 collectTables,43 collectTables,
44} from "@executor-js/api/server";44} from "@executor-js/api/server";
45import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google";
46import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
47import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";45import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";
48import {46import { type AnyPlugin } from "@executor-js/sdk";
49 IntegrationSlug,
50 type AnyPlugin,
51 type FirstPartyOAuthClientConfig,
52} from "@executor-js/sdk";
53 47
54import executorConfig from "../../executor.config";48import executorConfig from "../../executor.config";
55import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout";49import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout";
56import { DbService } from "../db/db";50import { DbService } from "../db/db";
57import { cloudDbProviderLayer } from "../db/fuma";51import { cloudDbProviderLayer } from "../db/fuma";
52import { firstPartyOAuthClientsFor } from "./first-party-oauth-clients";
58 53
59export { makeExecutionStack } from "@executor-js/api/server";54export { makeExecutionStack } from "@executor-js/api/server";
60 55
@@ -95,98 +90,6 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 34 unmodified lines
95 */90 */
96export const CLOUD_MOUNT_PREFIX = "/api" as const;91export const CLOUD_MOUNT_PREFIX = "/api" as const;
97 92
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.
104const 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
120const 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.
138export 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
190export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({93export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({
191 // SSRF / private-network egress guard. Config-driven, NOT a test flag:94 // SSRF / private-network egress guard. Config-driven, NOT a test flag:
192 // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);95 // 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
198 // WorkOS Vault is cloud's credential storage implementation detail, not a101 // WorkOS Vault is cloud's credential storage implementation detail, not a
199 // user-selectable provider surface.102 // user-selectable provider surface.
200 exposeCredentialProviders: false,103 exposeCredentialProviders: false,
201 firstPartyOAuthClients: cloudFirstPartyOAuthClients(),104 firstPartyOAuthClients: firstPartyOAuthClientsFor(env),
202 // Workers cancel request-scoped I/O once the response settles; the ambient105 // Workers cancel request-scoped I/O once the response settles; the ambient
203 // `waitUntil` binds to the in-flight invocation (HTTP request or DO call),106 // `waitUntil` binds to the in-flight invocation (HTTP request or DO call),
204 // so stale tool-catalog rebuilds that outlive a read still converge.107 // 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 @@
1import { env } from "cloudflare:workers";1import { describe, expect, it } from "@effect/vitest";
2import { beforeAll, describe, expect, it } from "@effect/vitest";
3 2
4import { cloudFirstPartyOAuthClients } from "./execution-stack";3import {
4 firstPartyOAuthClientsFor,
5 type FirstPartyOAuthClientEnv,
6} from "./first-party-oauth-clients";
7
8const 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
37describe("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});
5 98
6// The reviewed consumer scope boundary of the Executor-owned Google app.99// The reviewed consumer scope boundary of the Executor-owned Google app.
7//100//
@@ -12,13 +105,9 @@ import { cloudFirstPartyOAuthClients } from "./execution-stack"; · 4 unmodified lines
12// scopes an `oauth.start` requests, and that admin scopes are refused).105// scopes an `oauth.start` requests, and that admin scopes are refused).
13const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`;106const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`;
14 107
15describe("cloud first-party oauth clients", () => {108describe("cloud first-party Google app", () => {
16 beforeAll(() => {109 const google = () =>
17 env.FIRST_PARTY_GOOGLE_CLIENT_ID = "test-google-client";110 firstPartyOAuthClientsFor(completeEnv).find((client) => client.name === "google");
18 env.FIRST_PARTY_GOOGLE_CLIENT_SECRET = "test-google-secret";
19 });
20
21 const google = () => cloudFirstPartyOAuthClients().find((client) => client.name === "google");
22 111
23 it("declares the Google app but withholds it from every listing", () => {112 it("declares the Google app but withholds it from every listing", () => {
24 const client = google();113 const client = google();
apps/cloud/src/engine/first-party-oauth-clients.ts+336−0
@@ -0,0 +1,336 @@
1import { FIGMA_SUPPORTED_OAUTH_SCOPES } from "@executor-js/plugin-openapi/presets";
2import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google";
3import {
4 MICROSOFT_AUTHORIZATION_URL,
5 MICROSOFT_TOKEN_URL,
6} from "@executor-js/plugin-openapi/providers/microsoft";
7import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
8import { 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. */
12export 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
43const 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
58const 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
87const 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
96const 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
119const 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
150const HUBSPOT_OPTIONAL_SCOPES = [
151 "content",
152 "crm.objects.custom.read",
153 "crm.schemas.custom.read",
154] as const;
155
156const 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.
179const 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
195const 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
204const 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. */
224export 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
72 // unset pair simply ships no first-party app for that provider. The72 // unset pair simply ships no first-party app for that provider. The
73 // registered callback on the provider side must be73 // registered callback on the provider side must be
74 // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`.74 // `${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;
75 FIRST_PARTY_GITHUB_CLIENT_ID?: string;85 FIRST_PARTY_GITHUB_CLIENT_ID?: string;
76 FIRST_PARTY_GITHUB_CLIENT_SECRET?: string;86 FIRST_PARTY_GITHUB_CLIENT_SECRET?: string;
77 // Endpoint overrides for the GitHub first-party app, so tests/dev can87 // Endpoint overrides for the GitHub first-party app, so tests/dev can
@@ -79,8 +89,18 @@ declare global { · 1 unmodified line
79 // production (the real github.com endpoints are the defaults).89 // production (the real github.com endpoints are the defaults).
80 FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string;90 FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string;
81 FIRST_PARTY_GITHUB_TOKEN_URL?: string;91 FIRST_PARTY_GITHUB_TOKEN_URL?: string;
92 FIRST_PARTY_GITLAB_CLIENT_ID?: string;
93 FIRST_PARTY_GITLAB_CLIENT_SECRET?: string;
82 FIRST_PARTY_GOOGLE_CLIENT_ID?: string;94 FIRST_PARTY_GOOGLE_CLIENT_ID?: string;
83 FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string;95 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;
84 FIRST_PARTY_SLACK_CLIENT_ID?: string;104 FIRST_PARTY_SLACK_CLIENT_ID?: string;
85 FIRST_PARTY_SLACK_CLIENT_SECRET?: string;105 FIRST_PARTY_SLACK_CLIENT_SECRET?: string;
86 106
packages/core/sdk/src/executor.ts+11−1
@@ -2000,6 +2000,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1999 unmodified lines
2000 readonly tokenUrl: string;2000 readonly tokenUrl: string;
2001 readonly grant: string;2001 readonly grant: string;
2002 readonly resource: string | null;2002 readonly resource: string | null;
2003 readonly tokenEndpointAuthMethod?: "body" | "basic";
2004 readonly tokenRequestFormat?: "form" | "json";
2003 }2005 }
2004 2006
2005 /** What drove a refresh: the pre-call expiry check (`proactive`), or an2007 /** 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
2307 clientSecret: firstParty.clientSecret,2309 clientSecret: firstParty.clientSecret,
2308 tokenUrl: firstParty.tokenUrl,2310 tokenUrl: firstParty.tokenUrl,
2309 grant: "authorization_code",2311 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 }),
2311 } satisfies RefreshClient;2319 } satisfies RefreshClient;
2312 }2320 }
2313 const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;2321 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
2439 // RFC 8707: keep the re-minted token bound to the same resource2447 // RFC 8707: keep the re-minted token bound to the same resource
2440 // (MCP servers require this on refresh).2448 // (MCP servers require this on refresh).
2441 resource: clientRow.resource ?? undefined,2449 resource: clientRow.resource ?? undefined,
2450 clientAuth: clientRow.tokenEndpointAuthMethod,
2451 requestFormat: clientRow.tokenRequestFormat,
2442 endpointUrlPolicy: config.oauthEndpointUrlPolicy,2452 endpointUrlPolicy: config.oauthEndpointUrlPolicy,
2443 fetch: config.fetch,2453 fetch: config.fetch,
2444 }).pipe(2454 }).pipe(
packages/core/sdk/src/oauth-client.ts+19−0
@@ -187,6 +187,25 @@ export interface FirstPartyOAuthClientConfig { · 186 unmodified lines
187 * GitHub Apps, whose capabilities are configured on the app and whose OAuth187 * GitHub Apps, whose capabilities are configured on the app and whose OAuth
188 * user-token flow does not use scopes. Omit for normal OAuth clients. */188 * user-token flow does not use scopes. Omit for normal OAuth clients. */
189 readonly authorizationScopes?: readonly string[];189 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";
190 /** Withdraw the app from every listing surface without retiring it. It stops209 /** Withdraw the app from every listing surface without retiring it. It stops
191 * appearing in `listClients` — so connect pickers and the agent-facing210 * appearing in `listClients` — so connect pickers and the agent-facing
192 * client list never offer it — while remaining fully resolvable by slug.211 * 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
199 ),199 ),
200 );200 );
201 201
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
202 it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () =>238 it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () =>
203 Effect.scoped(239 Effect.scoped(
204 Effect.gen(function* () {240 Effect.gen(function* () {
packages/core/sdk/src/oauth-helpers.test.ts+183−1
@@ -6,7 +6,8 @@ · 5 unmodified lines
6// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
7 7
8import { describe, expect, it } from "@effect/vitest";8import { describe, expect, it } from "@effect/vitest";
9import { Cause, Effect, Exit, Ref } from "effect";9import { Cause, Effect, Exit, Ref, Schema } from "effect";
10import type * as Tracer from "effect/Tracer";
10import { HttpServerResponse } from "effect/unstable/http";11import { HttpServerResponse } from "effect/unstable/http";
11 12
12import {13import {
@@ -34,8 +35,11 @@ interface TokenCall { · 21 unmodified lines
34 readonly url: string;35 readonly url: string;
35 readonly headers: Readonly<Record<string, string>>;36 readonly headers: Readonly<Record<string, string>>;
36 readonly body: URLSearchParams;37 readonly body: URLSearchParams;
38 readonly jsonBody: unknown;
37}39}
38 40
41const decodeJsonBody = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
42
39type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse;43type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse;
40 44
41const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse =>45const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse =>
@@ -52,6 +56,9 @@ const serveTokenEndpoint = (handler: TokenHandler) => · 10 unmodified lines
52 url: request.url ?? "/",56 url: request.url ?? "/",
53 headers: request.headers,57 headers: request.headers,
54 body: new URLSearchParams(bodyText),58 body: new URLSearchParams(bodyText),
59 jsonBody: request.headers["content-type"]?.startsWith("application/json")
60 ? decodeJsonBody(bodyText)
61 : null,
55 };62 };
56 yield* Ref.update(calls, (all) => [...all, call]);63 yield* Ref.update(calls, (all) => [...all, call]);
57 return handler(call);64 return handler(call);
@@ -103,6 +110,45 @@ const tokenResponse = · 45 unmodified lines
103 () =>110 () =>
104 json(200, body);111 json(200, body);
105 112
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. */
115interface RecordedSpan {
116 readonly attributes: Map<string, unknown>;
117 endExit?: unknown;
118}
119
120const 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
106const tokenResponseFetch =152const tokenResponseFetch =
107 (body: unknown): typeof globalThis.fetch =>153 (body: unknown): typeof globalThis.fetch =>
108 async () =>154 async () =>
@@ -260,6 +306,56 @@ describe("buildAuthorizationUrl", () => { · 151 unmodified lines
260});306});
261 307
262describe("exchangeAuthorizationCode", () => {308describe("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
263 it.effect("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", () =>359 it.effect("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", () =>
264 withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>360 withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
265 Effect.gen(function* () {361 Effect.gen(function* () {
@@ -765,6 +861,9 @@ describe("exchangeAuthorizationCode", () => { · 499 unmodified lines
765 }),861 }),
766 );862 );
767 863
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.
768 it.effect("propagates RFC 6749 error_description text in the OAuth2Error", () =>867 it.effect("propagates RFC 6749 error_description text in the OAuth2Error", () =>
769 withTokenEndpoint(868 withTokenEndpoint(
770 () =>869 () =>
@@ -778,6 +877,7 @@ describe("exchangeAuthorizationCode", () => { · 7 unmodified lines
778 exchangeAuthorizationCode({877 exchangeAuthorizationCode({
779 tokenUrl,878 tokenUrl,
780 clientId: "cid",879 clientId: "cid",
880 clientSecret: "csecret",
781 redirectUrl: "https://cb",881 redirectUrl: "https://cb",
782 codeVerifier: "v",882 codeVerifier: "v",
783 code: "c",883 code: "c",
@@ -786,10 +886,67 @@ describe("exchangeAuthorizationCode", () => { · 2 unmodified lines
786 expect(Exit.isFailure(exit)).toBe(true);886 expect(Exit.isFailure(exit)).toBe(true);
787 if (!Exit.isFailure(exit)) return;887 if (!Exit.isFailure(exit)) return;
788 expect(JSON.stringify(exit.cause)).toContain("Code expired");888 expect(JSON.stringify(exit.cause)).toContain("Code expired");
889 const failure = Cause.squash(exit.cause) as OAuth2Error;
890 expect(failure.error).toBe("invalid_grant");
789 }),891 }),
790 ),892 ),
791 );893 );
792 894
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
793 it.effect("includes HTTP status and body preview for non-OAuth token endpoint errors", () =>950 it.effect("includes HTTP status and body preview for non-OAuth token endpoint errors", () =>
794 withTokenEndpoint(951 withTokenEndpoint(
795 () => HttpServerResponse.text("route not found", { status: 404 }),952 () => HttpServerResponse.text("route not found", { status: 404 }),
@@ -1192,6 +1349,31 @@ describe("exchangeClientCredentials", () => { · 396 unmodified lines
1192});1349});
1193 1350
1194describe("refreshAccessToken", () => {1351describe("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
1195 it.effect("normalizes Slack's comma-delimited scopes on refresh", () =>1377 it.effect("normalizes Slack's comma-delimited scopes on refresh", () =>
1196 Effect.gen(function* () {1378 Effect.gen(function* () {
1197 const result = yield* refreshAccessToken({1379 const result = yield* refreshAccessToken({
packages/core/sdk/src/oauth-helpers.ts+169−33
@@ -641,8 +641,23 @@ const oauthErrorFromResponseBody = ( · 640 unmodified lines
641 });641 });
642};642};
643 643
644const 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. */
653const redactSubmittedClientSecret = (
654 text: string,
655 clientSecret: string | null | undefined,
656): string => (clientSecret ? text.replaceAll(clientSecret, "[redacted]") : text);
657
658const toOAuth2Error = (cause: unknown, clientSecret?: string | null): OAuth2Error => {
645 if (isOAuth2Error(cause)) return cause;659 if (isOAuth2Error(cause)) return cause;
660 const scrub = (text: string): string => redactSubmittedClientSecret(text, clientSecret);
646 if (typeof cause === "object" && cause !== null) {661 if (typeof cause === "object" && cause !== null) {
647 const c = cause as {662 const c = cause as {
648 error?: unknown;663 error?: unknown;
@@ -657,8 +672,10 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 8 unmodified lines
657 ? c.message672 ? c.message
658 : undefined;673 : undefined;
659 return new OAuth2Error({674 return new OAuth2Error({
660 message: `OAuth token exchange failed: ${description ?? code ?? "unknown error"}`,675 message: scrub(`OAuth token exchange failed: ${description ?? code ?? "unknown error"}`),
661 error: code,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),
662 cause,679 cause,
663 });680 });
664 }681 }
@@ -674,13 +691,18 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 9 unmodified lines
674 *691 *
675 * `fallbackMessage` is for the paths that hold the error Response directly692 * `fallbackMessage` is for the paths that hold the error Response directly
676 * rather than catching a thrown oauth4webapi error: there is no library693 * 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. */
678const toOAuth2ErrorWithHttpSummary = (699const toOAuth2ErrorWithHttpSummary = (
679 cause: unknown,700 cause: unknown,
680 options?: { readonly fallbackMessage?: string },701 options?: { readonly fallbackMessage?: string; readonly clientSecret?: string | null },
681): Effect.Effect<OAuth2Error> => {702): Effect.Effect<OAuth2Error> => {
682 if (isOAuth2Error(cause)) return Effect.succeed(cause);703 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);
684 const response = responseFromOAuthErrorCause(cause);706 const response = responseFromOAuthErrorCause(cause);
685 if (!response) {707 if (!response) {
686 // No Response, but possibly a body the library already parsed off one it708 // No Response, but possibly a body the library already parsed off one it
@@ -704,8 +726,8 @@ const toOAuth2ErrorWithHttpSummary = ( · 17 unmodified lines
704 // exactly the leak the message allowlist exists to prevent. The other726 // exactly the leak the message allowlist exists to prevent. The other
705 // branches keep their cause because theirs is diagnostic, not a body.727 // branches keep their cause because theirs is diagnostic, not a body.
706 new OAuth2Error({728 new OAuth2Error({
707 message: `${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`,729 message: scrub(`${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`),
708 error: base.error ?? envelope?.error,730 error: base.error ?? (envelope?.error === undefined ? undefined : scrub(envelope.error)),
709 status: PARSED_BODY_CAUSE_STATUS,731 status: PARSED_BODY_CAUSE_STATUS,
710 }),732 }),
711 );733 );
@@ -727,20 +749,37 @@ const toOAuth2ErrorWithHttpSummary = ( · 15 unmodified lines
727 : `${headline}: ${recovered.code}${749 : `${headline}: ${recovered.code}${
728 recovered.description === undefined ? "" : ` — ${recovered.description}`750 recovered.description === undefined ? "" : ` — ${recovered.description}`
729 }`;751 }`;
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 ?? "");
730 return new OAuth2Error({763 return new OAuth2Error({
731 message: `${described} (${summary})`,764 message: scrub(`${described} (${summary})`),
732 error: base.error ?? recovered?.code,765 error: code === undefined ? undefined : scrub(code),
733 // Carried even when no code was recovered: the status is what tells a766 // Carried even when no code was recovered: the status is what tells a
734 // caller whether the AS refused (4xx — permanent, stop) or stumbled (5xx767 // caller whether the AS refused (4xx — permanent, stop) or stumbled (5xx
735 // — retry). Most real refusals arrive with no code at all.768 // — retry). Most real refusals arrive with no code at all.
736 status: response.status,769 status: response.status,
737 cause,770 ...(causeEchoesSecret ? {} : { cause }),
738 });771 });
739 });772 });
740};773};
741 774
742const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2Error> =>775/** Curried on the submitted client secret so every `Effect.catch` site names
743 toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error)));776 * the credential its request carried — the scrub cannot work without it. */
777const 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 );
744 783
745/** Fail from a token-endpoint error Response the caller holds directly — the784/** Fail from a token-endpoint error Response the caller holds directly — the
746 * `genericTokenEndpointRequest` paths, where oauth4webapi hands back the raw785 * `genericTokenEndpointRequest` paths, where oauth4webapi hands back the raw
@@ -751,8 +790,9 @@ const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2E · 4 unmodified lines
751const failOAuth2FromErrorResponse = (790const failOAuth2FromErrorResponse = (
752 response: Response,791 response: Response,
753 fallbackMessage: string,792 fallbackMessage: string,
793 clientSecret: string | null | undefined,
754): Effect.Effect<never, OAuth2Error> =>794): Effect.Effect<never, OAuth2Error> =>
755 toOAuth2ErrorWithHttpSummary(response, { fallbackMessage }).pipe(795 toOAuth2ErrorWithHttpSummary(response, { fallbackMessage, clientSecret }).pipe(
756 Effect.flatMap((error) => Effect.fail(error)),796 Effect.flatMap((error) => Effect.fail(error)),
757 );797 );
758 798
@@ -1069,6 +1109,9 @@ export type ExchangeAuthorizationCodeInput = { · 310 unmodified lines
1069 readonly codeVerifier: string;1109 readonly codeVerifier: string;
1070 readonly code: string;1110 readonly code: string;
1071 readonly clientAuth?: ClientAuthMethod;1111 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";
1072 readonly idTokenSigningAlgValuesSupported?: readonly string[];1115 readonly idTokenSigningAlgValuesSupported?: readonly string[];
1073 /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on1116 /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on
1074 * the token request when the client knows the resource it intends1117 * the token request when the client knows the resource it intends
@@ -1079,6 +1122,59 @@ export type ExchangeAuthorizationCodeInput = { · 4 unmodified lines
1079 readonly fetch?: typeof globalThis.fetch;1122 readonly fetch?: typeof globalThis.fetch;
1080};1123};
1081 1124
1125const 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
1132const 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
1082export const exchangeAuthorizationCode = (1178export const exchangeAuthorizationCode = (
1083 input: ExchangeAuthorizationCodeInput,1179 input: ExchangeAuthorizationCodeInput,
1084): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>1180): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
@@ -1106,24 +1202,37 @@ export const exchangeAuthorizationCode = ( · 21 unmodified lines
1106 if (input.resource) {1202 if (input.resource) {
1107 params.set("resource", input.resource);1203 params.set("resource", input.resource);
1108 }1204 }
1109 const response = await oauth.genericTokenEndpointRequest(1205 const response =
1110 as,1206 input.requestFormat === "json"
1111 client,1207 ? await jsonTokenEndpointRequest({
1112 clientAuth,1208 tokenUrl: input.tokenUrl,
1113 "authorization_code",1209 clientId: input.clientId,
1114 params,1210 clientSecret: input.clientSecret,
1115 oauth4webapiRequestOptions(1211 clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,
1116 input.tokenUrl,1212 grantType: "authorization_code",
1117 input.timeoutMs,1213 parameters: Object.fromEntries(params),
1118 input.endpointUrlPolicy,1214 timeoutMs: input.timeoutMs,
1119 input.fetch,1215 endpointUrlPolicy: input.endpointUrlPolicy,
1120 ),1216 fetch: input.fetch,
1121 );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 );
1122 return await processTokenEndpointResponse(as, client, response);1231 return await processTokenEndpointResponse(as, client, response);
1123 },1232 },
1124 catch: (cause) => cause,1233 catch: (cause) => cause,
1125 }).pipe(1234 }).pipe(
1126 Effect.catch(failOAuth2WithHttpSummary),1235 Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
1127 withTokenRequestSpan({1236 withTokenRequestSpan({
1128 grantType: "authorization_code",1237 grantType: "authorization_code",
1129 tokenUrl: input.tokenUrl,1238 tokenUrl: input.tokenUrl,
@@ -1186,7 +1295,7 @@ export const exchangeClientCredentials = ( · 56 unmodified lines
1186 },1295 },
1187 catch: (cause) => cause,1296 catch: (cause) => cause,
1188 }).pipe(1297 }).pipe(
1189 Effect.catch(failOAuth2WithHttpSummary),1298 Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
1190 withTokenRequestSpan({1299 withTokenRequestSpan({
1191 grantType: "client_credentials",1300 grantType: "client_credentials",
1192 tokenUrl: input.tokenUrl,1301 tokenUrl: input.tokenUrl,
@@ -1208,6 +1317,9 @@ export type RefreshAccessTokenInput = { · 15 unmodified lines
1208 readonly scopes?: readonly string[];1317 readonly scopes?: readonly string[];
1209 readonly scopeSeparator?: string;1318 readonly scopeSeparator?: string;
1210 readonly clientAuth?: ClientAuthMethod;1319 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";
1211 readonly idTokenSigningAlgValuesSupported?: readonly string[];1323 readonly idTokenSigningAlgValuesSupported?: readonly string[];
1212 /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on1324 /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on
1213 * refresh requests so the new access token's audience is bound to1325 * refresh requests so the new access token's audience is bound to
@@ -1241,6 +1353,26 @@ export const refreshAccessToken = ( · 27 unmodified lines
1241 }1353 }
1242 const additionalParameters =1354 const additionalParameters =
1243 Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;1355 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 }
1244 const response = await oauth.refreshTokenGrantRequest(1376 const response = await oauth.refreshTokenGrantRequest(
1245 as,1377 as,
1246 client,1378 client,
@@ -1265,7 +1397,7 @@ export const refreshAccessToken = ( · 18 unmodified lines
1265 },1397 },
1266 catch: (cause) => cause,1398 catch: (cause) => cause,
1267 }).pipe(1399 }).pipe(
1268 Effect.catch(failOAuth2WithHttpSummary),1400 Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
1269 withTokenRequestSpan({1401 withTokenRequestSpan({
1270 grantType: "refresh_token",1402 grantType: "refresh_token",
1271 tokenUrl: input.tokenUrl,1403 tokenUrl: input.tokenUrl,
@@ -1375,10 +1507,14 @@ export const exchangeSubjectTokenForIdJag = ( · 103 unmodified lines
1375 );1507 );
1376 },1508 },
1377 catch: (cause) => cause,1509 catch: (cause) => cause,
1378 }).pipe(Effect.catch(failOAuth2WithHttpSummary));1510 }).pipe(Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)));
1379 1511
1380 if (!response.ok) {1512 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 );
1382 }1518 }
1383 1519
1384 // Nothing else reads this body, so it is consumed directly. A read failure1520 // Nothing else reads this body, so it is consumed directly. A read failure
@@ -1485,7 +1621,7 @@ export const redeemIdJagAssertion = ( · 100 unmodified lines
1485 },1621 },
1486 catch: (cause) => cause,1622 catch: (cause) => cause,
1487 }).pipe(1623 }).pipe(
1488 Effect.catch(failOAuth2WithHttpSummary),1624 Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
1489 withTokenRequestSpan({1625 withTokenRequestSpan({
1490 grantType: JWT_BEARER_GRANT_TYPE,1626 grantType: JWT_BEARER_GRANT_TYPE,
1491 tokenUrl: input.tokenUrl,1627 tokenUrl: input.tokenUrl,
packages/core/sdk/src/oauth-service.ts+23−3
@@ -509,6 +509,8 @@ interface LoadedOAuthClient { · 508 unmodified lines
509 /** Resolved literal secret (read from the provider via the stored item id). */509 /** Resolved literal secret (read from the provider via the stored item id). */
510 readonly clientSecret: string;510 readonly clientSecret: string;
511 readonly resource: string | null;511 readonly resource: string | null;
512 readonly tokenEndpointAuthMethod?: "body" | "basic";
513 readonly tokenRequestFormat?: "form" | "json";
512}514}
513 515
514/** Where an OAuth app's client secret is stored in the default writable516/** Where an OAuth app's client secret is stored in the default writable
@@ -611,6 +613,8 @@ export const loadedFirstPartyClient = ( · 96 unmodified lines
611 readonly clientId: string;613 readonly clientId: string;
612 readonly clientSecret: string;614 readonly clientSecret: string;
613 readonly resource: string | null;615 readonly resource: string | null;
616 readonly tokenEndpointAuthMethod?: "body" | "basic";
617 readonly tokenRequestFormat?: "form" | "json";
614} => ({618} => ({
615 slug: String(firstPartyOAuthClientSlug(config.name)),619 slug: String(firstPartyOAuthClientSlug(config.name)),
616 authorizationUrl: config.authorizationUrl,620 authorizationUrl: config.authorizationUrl,
@@ -619,6 +623,12 @@ export const loadedFirstPartyClient = ( · 2 unmodified lines
619 clientId: config.clientId,623 clientId: config.clientId,
620 clientSecret: config.clientSecret,624 clientSecret: config.clientSecret,
621 resource: config.resource ?? null,625 resource: config.resource ?? null,
626 ...(config.tokenEndpointAuthMethod === undefined
627 ? {}
628 : { tokenEndpointAuthMethod: config.tokenEndpointAuthMethod }),
629 ...(config.tokenRequestFormat === undefined
630 ? {}
631 : { tokenRequestFormat: config.tokenRequestFormat }),
622});632});
623 633
624export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {634export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
@@ -1683,6 +1693,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1058 unmodified lines
1683 : scopePolicy.kind === "discover"1693 : scopePolicy.kind === "discover"
1684 ? requestedScopes1694 ? requestedScopes
1685 : yield* filterAuthorizationCodeScopes(client, requestedScopes);1695 : yield* filterAuthorizationCodeScopes(client, requestedScopes);
1696 const completeAuthorizationScopes = dedupeScopes([
1697 ...authorizationRequestedScopes,
1698 ...(firstParty?.additionalAuthorizationScopes ?? []),
1699 ]);
1686 1700
1687 // authorization_code: persist a session + build the authorize URL.1701 // authorization_code: persist a session + build the authorize URL.
1688 const verifier = createPkceCodeVerifier();1702 const verifier = createPkceCodeVerifier();
@@ -1744,7 +1758,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines
1744 payload: {1758 payload: {
1745 owner: input.owner,1759 owner: input.owner,
1746 clientOwner: input.clientOwner,1760 clientOwner: input.clientOwner,
1747 requestedScopes: authorizationRequestedScopes,1761 requestedScopes: completeAuthorizationScopes,
1748 },1762 },
1749 expires_at: expiresAt,1763 expires_at: expiresAt,
1750 created_at: now,1764 created_at: now,
@@ -1757,14 +1771,18 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 6 unmodified lines
1757 authorizationUrl: client.authorizationUrl,1771 authorizationUrl: client.authorizationUrl,
1758 clientId: client.clientId,1772 clientId: client.clientId,
1759 redirectUrl: flowRedirectUri,1773 redirectUrl: flowRedirectUri,
1760 scopes: authorizationRequestedScopes,1774 scopes: completeAuthorizationScopes,
1761 state: providerState,1775 state: providerState,
1762 codeChallenge: challenge,1776 codeChallenge: challenge,
1777 scopeSeparator: firstParty?.authorizationScopeSeparator,
1763 resource: client.resource ?? undefined,1778 resource: client.resource ?? undefined,
1764 // Provider quirks (Google: access_type=offline + prompt=consent) —1779 // Provider quirks (Google: access_type=offline + prompt=consent) —
1765 // without these Google returns no refresh token and won't re-consent1780 // without these Google returns no refresh token and won't re-consent
1766 // to widen scopes on reconnect.1781 // to widen scopes on reconnect.
1767 extraParams: providerAuthorizeExtras(client.authorizationUrl),1782 extraParams: {
1783 ...providerAuthorizeExtras(client.authorizationUrl),
1784 ...(firstParty?.authorizationExtraParams ?? {}),
1785 },
1768 endpointUrlPolicy: deps.endpointUrlPolicy,1786 endpointUrlPolicy: deps.endpointUrlPolicy,
1769 }),1787 }),
1770 catch: (cause) =>1788 catch: (cause) =>
@@ -1883,6 +1901,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 112 unmodified lines
1883 redirectUrl: session.redirectUrl,1901 redirectUrl: session.redirectUrl,
1884 codeVerifier: session.pkceVerifier,1902 codeVerifier: session.pkceVerifier,
1885 code: input.code,1903 code: input.code,
1904 clientAuth: client.tokenEndpointAuthMethod,
1905 requestFormat: client.tokenRequestFormat,
1886 resource: client.resource ?? undefined,1906 resource: client.resource ?? undefined,
1887 endpointUrlPolicy: deps.endpointUrlPolicy,1907 endpointUrlPolicy: deps.endpointUrlPolicy,
1888 fetch,1908 fetch,
packages/plugins/openapi/src/sdk/presets.ts+2−2
@@ -40,10 +40,10 @@ export const FIGMA_SUPPORTED_OAUTH_SCOPES = [ · 39 unmodified lines
40 "file_dev_resources:write",40 "file_dev_resources:write",
41 "file_metadata:read",41 "file_metadata:read",
42 "file_versions:read",42 "file_versions:read",
43 "folder_metadata:read",
44 "folders:read",
43 "library_assets:read",45 "library_assets:read",
44 "library_content:read",46 "library_content:read",
45 "project_metadata:read",
46 "projects:read",
47 "team_library_content:read",47 "team_library_content:read",
48 "webhooks:read",48 "webhooks:read",
49 "webhooks:write",49 "webhooks:write",