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
4343 collectTables,
4444 } from "@executor-js/api/server";
4545 import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google";
46+import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
4647 import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";
47−import type { AnyPlugin, FirstPartyOAuthClientConfig } from "@executor-js/sdk";
48+import {
49+ IntegrationSlug,
50+ type AnyPlugin,
51+ type FirstPartyOAuthClientConfig,
52+} from "@executor-js/sdk";
4853
4954 import executorConfig from "../../executor.config";
5055 import { DbService } from "../db/db";
@@ -136,6 +141,20 @@ const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] = · 85 unmodified lines
136141 },
137142 ]
138143 : []),
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+ : []),
139158 ];
140159
141160 export 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
6767 FIRST_PARTY_GITHUB_TOKEN_URL?: string;
6868 FIRST_PARTY_GOOGLE_CLIENT_ID?: string;
6969 FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string;
70+ FIRST_PARTY_SLACK_CLIENT_ID?: string;
71+ FIRST_PARTY_SLACK_CLIENT_SECRET?: string;
7072
7173 // Billing
7274 AUTUMN_SECRET_KEY?: string;
packages/core/sdk/src/oauth-client.ts+8−2
@@ -122,13 +122,19 @@ export interface FirstPartyOAuthClientConfig { · 121 unmodified lines
122122 readonly clientId: string;
123123 /** Literal secret from host env. Empty string for a public/PKCE client. */
124124 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;
125128 /** Integrations this app is intended for, used by pickers to rank it as the
126129 * exact-match default for those integrations. Endpoint-host matching still
127130 * applies when omitted. */
128131 readonly integrations?: readonly IntegrationSlug[];
129132 /** OAuth scopes this deployment permits the app to request. Omit to allow
130− * every scope declared by a matching integration. When present, OAuth start
131− * and completion fail unless every requested scope belongs to this set. */
133+ * every scope declared by a matching integration. For declared scopes,
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. */
132138 readonly allowedScopes?: readonly string[];
133139 }
134140
packages/core/sdk/src/oauth-first-party.test.ts+6−1
@@ -220,7 +220,11 @@ describe("first-party oauth clients", () => { · 219 unmodified lines
220220 const { executor } = yield* makeTestWorkspaceHarness({
221221 plugins,
222222 firstPartyOAuthClients: [
223− { ...firstPartyClientFor(server), allowedScopes: ["openid", "read"] },
223+ {
224+ ...firstPartyClientFor(server),
225+ resource: server.resourceUrl,
226+ allowedScopes: ["openid", "read"],
227+ },
224228 ],
225229 });
226230 yield* executor.acme.seed();
@@ -244,6 +248,7 @@ describe("first-party oauth clients", () => { · 17 unmodified lines
244248 allowedScopes: ["openid", "read"],
245249 });
246250 expect(firstParty.clientId).toBe("test-client");
251+ expect(firstParty.resource).toBe(server.resourceUrl);
247252 expect("clientSecret" in firstParty).toBe(false);
248253 }),
249254 ),
packages/core/sdk/src/oauth-scope-union.test.ts+46−0
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 ToolName,
1212 } from "./ids";
1313 import type { AuthMethodDescriptor } from "./integration";
14+import { firstPartyOAuthClientSlug } from "./oauth-client";
1415 import { definePlugin, type IntegrationRecord } from "./plugin";
1516 import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
1617 import { serveTestHttpApp } from "./testing";
@@ -532,6 +533,51 @@ describe("oauth.start integration-driven scopes", () => { · 515 unmodified lines
532533 ),
533534 );
534535
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+
535581 it.effect("(j) caps server-advertised resource scopes so the authorize URL stays bounded", () =>
536582 Effect.scoped(
537583 Effect.gen(function* () {
packages/core/sdk/src/oauth-service.ts+23−16
@@ -513,7 +513,7 @@ export const loadedFirstPartyClient = ( · 512 unmodified lines
513513 readonly grant: OAuthGrant;
514514 readonly clientId: string;
515515 readonly clientSecret: string;
516− readonly resource: null;
516+ readonly resource: string | null;
517517 } => ({
518518 slug: String(firstPartyOAuthClientSlug(config.name)),
519519 authorizationUrl: config.authorizationUrl,
@@ -521,7 +521,7 @@ export const loadedFirstPartyClient = ( · 1 unmodified line
521521 grant: "authorization_code",
522522 clientId: config.clientId,
523523 clientSecret: config.clientSecret,
524− resource: null,
524+ resource: config.resource ?? null,
525525 });
526526
527527 export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
@@ -1026,7 +1026,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 498 unmodified lines
10261026 grant: "authorization_code",
10271027 authorizationUrl: config.authorizationUrl,
10281028 tokenUrl: config.tokenUrl,
1029− resource: null,
1029+ resource: config.resource ?? null,
10301030 clientId: config.clientId,
10311031 origin: {
10321032 kind: "first_party",
@@ -1213,25 +1213,32 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 180 unmodified lines
12131213 }),
12141214 ),
12151215 );
1216+ const firstParty = firstPartyFlow ? firstPartyBySlug.get(String(input.client)) : undefined;
12161217 const requestedScopes =
12171218 scopePolicy.kind === "discover"
1218− ? yield* discoverScopesForResource(client.resource).pipe(
1219− Effect.mapError(
1220− (cause) =>
1221− new OAuthStartError({
1222− // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuthDiscoveryError carries a typed `message` field
1223− message: `Failed to discover OAuth scopes: ${cause.message}`,
1224− }),
1225− ),
1226− )
1219+ ? yield* (() => {
1220+ const discovered = discoverScopesForResource(client.resource).pipe(
1221+ Effect.mapError(
1222+ (cause) =>
1223+ new OAuthStartError({
1224+ // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuthDiscoveryError carries a typed `message` field
1225+ message: `Failed to discover OAuth scopes: ${cause.message}`,
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+ })()
12271235 : dedupeScopes(scopePolicy.scopes);
12281236
12291237 // An explicitly scope-limited first-party app is an authorization
1230− // boundary, not picker decoration. Endpoint matching can associate one
1231− // Google client with every Google API, so enforce the complete requested
1232− // set here before persisting an OAuth session or redirecting the browser.
1238+ // boundary, not picker decoration. Endpoint matching and provider
1239+ // discovery can surface capabilities outside the registered app, so
1240+ // enforce the complete requested set before persisting or redirecting.
12331241 if (firstPartyFlow) {
1234− const firstParty = firstPartyBySlug.get(String(input.client));
12351242 if (
12361243 firstParty !== undefined &&
12371244 !firstPartyOAuthClientAllowsScopes(firstParty, requestedScopes)
packages/react/src/components/oauth-app-setup.ts+4−29
@@ -1,3 +1,7 @@
1+import { slackMcpUserScopes } from "../lib/slack-mcp-oauth";
2+
3+export { slackMcpUserScopes } from "../lib/slack-mcp-oauth";
4+
15 export interface OAuthAppSetup {
26 readonly id: string;
37 readonly title: string;
@@ -29,35 +33,6 @@ interface SlackManifest { · 25 unmodified lines
2933 };
3034 }
3135
32−export 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−
6136 const slackManifest = (callbackUrl: string): SlackManifest => ({
6237 display_information: { name: "Executor" },
6338 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. */
5+export 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;