Skip to content

Commit 63ea028

Browse files
authored
Add Slack first-party OAuth (#1640)
1 parent f64028a commit 63ea028

8 files changed

Lines changed: 141 additions & 49 deletions

File tree

apps/cloud/src/engine/execution-stack.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,13 @@ import {
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[] =
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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ declare global {
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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,19 @@ export interface FirstPartyOAuthClientConfig {
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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,11 @@ describe("first-party oauth clients", () => {
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", () => {
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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
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", () => {
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

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -513,15 +513,15 @@ export const loadedFirstPartyClient = (
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,
520520
tokenUrl: config.tokenUrl,
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 => {
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 => {
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

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {
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: {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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;

0 commit comments

Comments
 (0)