History

Add Slack first-party OAuth (#1640)

Rhys Sullivan committed 2 months ago

Commit 63ea0281 parent f64028a8 files · +141 −49Browse files

Unified · Split

8 files
  1. apps/cloud/src/engine/execution-stack.ts +20 −1
  2. apps/cloud/src/env-augment.d.ts +2 −0
  3. packages/core/sdk/src/oauth-client.ts +8 −2
  4. packages/core/sdk/src/oauth-first-party.test.ts +6 −1
  5. packages/core/sdk/src/oauth-scope-union.test.ts +46 −0
  6. packages/core/sdk/src/oauth-service.ts +23 −16
  7. packages/react/src/components/oauth-app-setup.ts +4 −29
  8. packages/react/src/lib/slack-mcp-oauth.ts +32 −0
apps/cloud/src/engine/execution-stack.ts+20−1
@@ -43,8 +43,13 @@ import { · 42 unmodified lines
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";45import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google";
46import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
46import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";47import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";
47import type { AnyPlugin, FirstPartyOAuthClientConfig } from "@executor-js/sdk";48import {
49 IntegrationSlug,
50 type AnyPlugin,
51 type FirstPartyOAuthClientConfig,
52} from "@executor-js/sdk";
48 53
49import executorConfig from "../../executor.config";54import executorConfig from "../../executor.config";
50import { DbService } from "../db/db";55import { DbService } from "../db/db";
@@ -136,6 +141,20 @@ const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] = · 85 unmodified lines
136 },141 },
137 ]142 ]
138 : []),143 : []),
144 ...(env.FIRST_PARTY_SLACK_CLIENT_ID && env.FIRST_PARTY_SLACK_CLIENT_SECRET
145 ? [
146 {
147 name: "slack",
148 authorizationUrl: "https://slack.com/oauth/v2_user/authorize",
149 tokenUrl: "https://slack.com/api/oauth.v2.user.access",
150 resource: "https://mcp.slack.com",
151 clientId: env.FIRST_PARTY_SLACK_CLIENT_ID,
152 clientSecret: env.FIRST_PARTY_SLACK_CLIENT_SECRET,
153 integrations: [IntegrationSlug.make("slack")],
154 allowedScopes: slackMcpUserScopes,
155 },
156 ]
157 : []),
139];158];
140 159
141export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({160export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({
apps/cloud/src/env-augment.d.ts+2−0
@@ -67,6 +67,8 @@ declare global { · 66 unmodified lines
67 FIRST_PARTY_GITHUB_TOKEN_URL?: string;67 FIRST_PARTY_GITHUB_TOKEN_URL?: string;
68 FIRST_PARTY_GOOGLE_CLIENT_ID?: string;68 FIRST_PARTY_GOOGLE_CLIENT_ID?: string;
69 FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string;69 FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string;
70 FIRST_PARTY_SLACK_CLIENT_ID?: string;
71 FIRST_PARTY_SLACK_CLIENT_SECRET?: string;
70 72
71 // Billing73 // Billing
72 AUTUMN_SECRET_KEY?: string;74 AUTUMN_SECRET_KEY?: string;
packages/core/sdk/src/oauth-client.ts+8−2
@@ -122,13 +122,19 @@ export interface FirstPartyOAuthClientConfig { · 121 unmodified lines
122 readonly clientId: string;122 readonly clientId: string;
123 /** Literal secret from host env. Empty string for a public/PKCE client. */123 /** Literal secret from host env. Empty string for a public/PKCE client. */
124 readonly clientSecret: string;124 readonly clientSecret: string;
125 /** RFC 8707 protected resource for MCP-style providers. Required when the
126 * integration discovers its OAuth scopes from resource metadata. */
127 readonly resource?: string | null;
125 /** Integrations this app is intended for, used by pickers to rank it as the128 /** Integrations this app is intended for, used by pickers to rank it as the
126 * exact-match default for those integrations. Endpoint-host matching still129 * exact-match default for those integrations. Endpoint-host matching still
127 * applies when omitted. */130 * applies when omitted. */
128 readonly integrations?: readonly IntegrationSlug[];131 readonly integrations?: readonly IntegrationSlug[];
129 /** OAuth scopes this deployment permits the app to request. Omit to allow132 /** OAuth scopes this deployment permits the app to request. Omit to allow
130 * every scope declared by a matching integration. When present, OAuth start133 * every scope declared by a matching integration. For declared scopes,
131 * and completion fail unless every requested scope belongs to this set. */134 * start and completion fail unless every requested scope belongs to this
135 * set. For MCP-style discovery, the provider's advertised scope catalog is
136 * capped to this set because it may include capabilities the registered app
137 * was not approved for. */
132 readonly allowedScopes?: readonly string[];138 readonly allowedScopes?: readonly string[];
133}139}
134 140
packages/core/sdk/src/oauth-first-party.test.ts+6−1
@@ -220,7 +220,11 @@ describe("first-party oauth clients", () => { · 219 unmodified lines
220 const { executor } = yield* makeTestWorkspaceHarness({220 const { executor } = yield* makeTestWorkspaceHarness({
221 plugins,221 plugins,
222 firstPartyOAuthClients: [222 firstPartyOAuthClients: [
223 { ...firstPartyClientFor(server), allowedScopes: ["openid", "read"] },223 {
224 ...firstPartyClientFor(server),
225 resource: server.resourceUrl,
226 allowedScopes: ["openid", "read"],
227 },
224 ],228 ],
225 });229 });
226 yield* executor.acme.seed();230 yield* executor.acme.seed();
@@ -244,6 +248,7 @@ describe("first-party oauth clients", () => { · 17 unmodified lines
244 allowedScopes: ["openid", "read"],248 allowedScopes: ["openid", "read"],
245 });249 });
246 expect(firstParty.clientId).toBe("test-client");250 expect(firstParty.clientId).toBe("test-client");
251 expect(firstParty.resource).toBe(server.resourceUrl);
247 expect("clientSecret" in firstParty).toBe(false);252 expect("clientSecret" in firstParty).toBe(false);
248 }),253 }),
249 ),254 ),
packages/core/sdk/src/oauth-scope-union.test.ts+46−0
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
11 ToolName,11 ToolName,
12} from "./ids";12} from "./ids";
13import type { AuthMethodDescriptor } from "./integration";13import type { AuthMethodDescriptor } from "./integration";
14import { firstPartyOAuthClientSlug } from "./oauth-client";
14import { definePlugin, type IntegrationRecord } from "./plugin";15import { definePlugin, type IntegrationRecord } from "./plugin";
15import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";16import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
16import { serveTestHttpApp } from "./testing";17import { serveTestHttpApp } from "./testing";
@@ -532,6 +533,51 @@ describe("oauth.start integration-driven scopes", () => { · 515 unmodified lines
532 ),533 ),
533 );534 );
534 535
536 it.effect(
537 "(i) a scope-limited first-party MCP app caps the provider's advertised scope catalog",
538 () =>
539 Effect.scoped(
540 Effect.gen(function* () {
541 const server = yield* serveMetadataServer({
542 prm: { scopesSupported: ["read", "write", "admin"] },
543 });
544 const plugins = [
545 memoryCredentialsPlugin(),
546 makeMcpScopePlugin({ scopes: null }),
547 ] as const;
548 const { executor } = yield* makeTestWorkspaceHarness({
549 plugins,
550 firstPartyOAuthClients: [
551 {
552 name: "acme",
553 authorizationUrl: server.authorizationEndpoint,
554 tokenUrl: server.tokenEndpoint,
555 resource: server.mcpResourceUrl,
556 clientId: "test-client",
557 clientSecret: "test-secret",
558 integrations: [INTEG],
559 allowedScopes: ["read", "write"],
560 },
561 ],
562 });
563 yield* executor.mcp.seed();
564
565 const started = yield* executor.oauth.start({
566 owner: "org",
567 client: firstPartyOAuthClientSlug("acme"),
568 clientOwner: "org",
569 name: ConnectionName.make("main"),
570 integration: INTEG,
571 template: TEMPLATE,
572 });
573 expect(started.status).toBe("redirect");
574 if (started.status !== "redirect") return;
575
576 expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["read", "write"]);
577 }),
578 ),
579 );
580
535 it.effect("(j) caps server-advertised resource scopes so the authorize URL stays bounded", () =>581 it.effect("(j) caps server-advertised resource scopes so the authorize URL stays bounded", () =>
536 Effect.scoped(582 Effect.scoped(
537 Effect.gen(function* () {583 Effect.gen(function* () {
packages/core/sdk/src/oauth-service.ts+23−16
@@ -513,7 +513,7 @@ export const loadedFirstPartyClient = ( · 512 unmodified lines
513 readonly grant: OAuthGrant;513 readonly grant: OAuthGrant;
514 readonly clientId: string;514 readonly clientId: string;
515 readonly clientSecret: string;515 readonly clientSecret: string;
516 readonly resource: null;516 readonly resource: string | null;
517} => ({517} => ({
518 slug: String(firstPartyOAuthClientSlug(config.name)),518 slug: String(firstPartyOAuthClientSlug(config.name)),
519 authorizationUrl: config.authorizationUrl,519 authorizationUrl: config.authorizationUrl,
@@ -521,7 +521,7 @@ export const loadedFirstPartyClient = ( · 1 unmodified line
521 grant: "authorization_code",521 grant: "authorization_code",
522 clientId: config.clientId,522 clientId: config.clientId,
523 clientSecret: config.clientSecret,523 clientSecret: config.clientSecret,
524 resource: null,524 resource: config.resource ?? null,
525});525});
526 526
527export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {527export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
@@ -1026,7 +1026,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 498 unmodified lines
1026 grant: "authorization_code",1026 grant: "authorization_code",
1027 authorizationUrl: config.authorizationUrl,1027 authorizationUrl: config.authorizationUrl,
1028 tokenUrl: config.tokenUrl,1028 tokenUrl: config.tokenUrl,
1029 resource: null,1029 resource: config.resource ?? null,
1030 clientId: config.clientId,1030 clientId: config.clientId,
1031 origin: {1031 origin: {
1032 kind: "first_party",1032 kind: "first_party",
@@ -1213,25 +1213,32 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 180 unmodified lines
1213 }),1213 }),
1214 ),1214 ),
1215 );1215 );
1216 const firstParty = firstPartyFlow ? firstPartyBySlug.get(String(input.client)) : undefined;
1216 const requestedScopes =1217 const requestedScopes =
1217 scopePolicy.kind === "discover"1218 scopePolicy.kind === "discover"
1218 ? yield* discoverScopesForResource(client.resource).pipe(1219 ? yield* (() => {
1219 Effect.mapError(1220 const discovered = discoverScopesForResource(client.resource).pipe(
1220 (cause) =>1221 Effect.mapError(
1221 new OAuthStartError({1222 (cause) =>
1222 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuthDiscoveryError carries a typed `message` field1223 new OAuthStartError({
1223 message: `Failed to discover OAuth scopes: ${cause.message}`,1224 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuthDiscoveryError carries a typed `message` field
1224 }),1225 message: `Failed to discover OAuth scopes: ${cause.message}`,
1225 ),1226 }),
1226 )1227 ),
1228 );
1229 if (firstParty?.allowedScopes === undefined) return discovered;
1230 const allowed = new Set(firstParty.allowedScopes);
1231 return discovered.pipe(
1232 Effect.map((scopes) => scopes.filter((scope) => allowed.has(scope))),
1233 );
1234 })()
1227 : dedupeScopes(scopePolicy.scopes);1235 : dedupeScopes(scopePolicy.scopes);
1228 1236
1229 // An explicitly scope-limited first-party app is an authorization1237 // An explicitly scope-limited first-party app is an authorization
1230 // boundary, not picker decoration. Endpoint matching can associate one1238 // boundary, not picker decoration. Endpoint matching and provider
1231 // Google client with every Google API, so enforce the complete requested1239 // discovery can surface capabilities outside the registered app, so
1232 // set here before persisting an OAuth session or redirecting the browser.1240 // enforce the complete requested set before persisting or redirecting.
1233 if (firstPartyFlow) {1241 if (firstPartyFlow) {
1234 const firstParty = firstPartyBySlug.get(String(input.client));
1235 if (1242 if (
1236 firstParty !== undefined &&1243 firstParty !== undefined &&
1237 !firstPartyOAuthClientAllowsScopes(firstParty, requestedScopes)1244 !firstPartyOAuthClientAllowsScopes(firstParty, requestedScopes)
packages/react/src/components/oauth-app-setup.ts+4−29
@@ -1,3 +1,7 @@
1import { slackMcpUserScopes } from "../lib/slack-mcp-oauth";
2
3export { slackMcpUserScopes } from "../lib/slack-mcp-oauth";
4
1export interface OAuthAppSetup {5export interface OAuthAppSetup {
2 readonly id: string;6 readonly id: string;
3 readonly title: string;7 readonly title: string;
@@ -29,35 +33,6 @@ interface SlackManifest { · 25 unmodified lines
29 };33 };
30}34}
31 35
32export const slackMcpUserScopes = [
33 "search:read.public",
34 "search:read.private",
35 "search:read.mpim",
36 "search:read.im",
37 "search:read.files",
38 "search:read.users",
39 "chat:write",
40 "channels:history",
41 "groups:history",
42 "mpim:history",
43 "im:history",
44 "canvases:read",
45 "canvases:write",
46 "users:read",
47 "users:read.email",
48 "reactions:write",
49 "reactions:read",
50 "emoji:read",
51 "files:read",
52 "channels:write",
53 "groups:write",
54 "im:write",
55 "mpim:write",
56 "channels:read",
57 "groups:read",
58 "mpim:read",
59] as const;
60
61const slackManifest = (callbackUrl: string): SlackManifest => ({36const slackManifest = (callbackUrl: string): SlackManifest => ({
62 display_information: { name: "Executor" },37 display_information: { name: "Executor" },
63 oauth_config: {38 oauth_config: {
packages/react/src/lib/slack-mcp-oauth.ts+32−0
@@ -0,0 +1,32 @@
1/** User scopes approved on Executor's Slack MCP OAuth app and embedded in the
2 * BYO-app creation manifest. Cloud uses the same list as its first-party
3 * discovery cap so Slack metadata cannot expand the requested grant beyond
4 * the provider-side registration. */
5export const slackMcpUserScopes = [
6 "search:read.public",
7 "search:read.private",
8 "search:read.mpim",
9 "search:read.im",
10 "search:read.files",
11 "search:read.users",
12 "chat:write",
13 "channels:history",
14 "groups:history",
15 "mpim:history",
16 "im:history",
17 "canvases:read",
18 "canvases:write",
19 "users:read",
20 "users:read.email",
21 "reactions:write",
22 "reactions:read",
23 "emoji:read",
24 "files:read",
25 "channels:write",
26 "groups:write",
27 "im:write",
28 "mpim:write",
29 "channels:read",
30 "groups:read",
31 "mpim:read",
32] as const;