History

Discover MCP OAuth scopes from server metadata at connect (#1032)

* Discover MCP OAuth scopes from server metadata at connect MCP-style integrations declare no OAuth scopes of their own; the scopes live on the server. Replace the declared/client scope union with an integration-driven scope policy: resolveOAuthScopePolicy returns either the template's declared scopes or a { kind: "discover" } marker. For the discover case, oauth.start reads the request scopes from the client's RFC 9728 protected-resource metadata, falling back to the RFC 8414 authorization-server metadata it names. Harden the transparent-DCR connect flow: carry the probe result through the bring-your-own-app fallback, match registered apps against discovered endpoints (requireEndpointMatch), and seed the register form with discovered scopes and token-endpoint auth methods via a shared registrationScopes helper. Add a self-host e2e scenario covering MCP OAuth scope discovery. Entire-Checkpoint: dc839672b8b0 * Bound MCP OAuth scope discovery against hostile or slow servers discoverScopesForResource takes its input entirely from a remote MCP server (the protected-resource metadata and the authorization servers it names). Add three bounds, each guarding a distinct failure: cap the authorization-server probe list at 3 (outbound request fan-out to server-named hosts), cap the discovered scope count at 100 (authorize-URL length), and wrap the whole sequence in a 30s timeout (total wall-clock; larger than the per-request timeout so it bounds the sequence, not a single slow-but-valid request). Tests: a 200-scope advertisement capped to 100, a four-AS list where the 3-server cap stops before the fourth (its scope never appears), and client_credentials reaching the discover path with the discovered scopes recorded on the connection. Entire-Checkpoint: f23d8ac3c4e2 * e2e: prove MCP OAuth scope discovery against a deployed emulate server Add an emulate-backed counterpart to the in-process scope-discovery selfhost test. It points at the @executor-js/emulate GitHub MCP `scope-discovery` instance, whose protected-resource metadata is silent on scopes so oauth.start must fall back to the authorization-server metadata it names, and asserts the authorize URL carries exactly the discovered scopes. Keeps the hermetic in-process test; adds real-deployment fidelity. * Format MCP OAuth scope discovery e2e Entire-Checkpoint: d3ea2960c9ec * Never seed RFC 8707 resource indicator from token endpoint DCR's resource fallback used the probe discovery URL, which collapses to the token endpoint for integrations without a real discovery URL. The token endpoint is not a resource identifier, so a non-MCP DCR client could register the token URL as its RFC 8707 resource indicator and have oauth.start probe it as a protected resource. Thread the genuine discovery URL (the MCP server URL) as a distinct resourceFallback; when absent, the indicator stays null as before. MCP flows are unchanged. Adds a regression test for the non-MCP case. ---------

Gjermund Garaba committed 4 months ago

Commit 5ecb95f1 parent f13cdea17 files · +1116 −112Browse files

Unified · Split

17 files
  1. e2e/selfhost/mcp-oauth-scope-discovery-emulate.test.ts +127 −0
  2. e2e/selfhost/mcp-oauth-scope-discovery.test.ts +123 −0
  3. packages/core/sdk/src/executor.ts +25 −17
  4. packages/core/sdk/src/oauth-scope-union.test.ts +431 −19
  5. packages/core/sdk/src/oauth-service.ts +129 −36
  6. packages/core/sdk/src/testing.ts +1 −0
  7. packages/core/sdk/src/testing/oauth-test-server.ts +7 −0
  8. packages/plugins/mcp/src/react/auth-method-config.test.ts +9 −3
  9. packages/plugins/mcp/src/sdk/plugin.test.ts +47 −0
  10. packages/plugins/mcp/src/sdk/plugin.ts +4 −3
  11. packages/react/src/components/add-account-modal.test.ts +50 −2
  12. packages/react/src/components/add-account-modal.tsx +64 −24
  13. packages/react/src/components/auth-template-editor.tsx +2 −2
  14. packages/react/src/components/oauth-client-form.test.ts +28 −0
  15. packages/react/src/components/oauth-client-form.tsx +39 −4
  16. packages/react/src/plugins/use-effective-oauth-client.test.ts +13 −0
  17. packages/react/src/plugins/use-effective-oauth-client.tsx +17 −2
e2e/selfhost/mcp-oauth-scope-discovery-emulate.test.ts+127−0
@@ -0,0 +1,127 @@
1// Selfhost-only, emulate-backed counterpart to mcp-oauth-scope-discovery.test.ts.
2// Same claim — an MCP integration declares NO oauth scopes, so `oauth.start`
3// DISCOVERS them from the server's metadata at connect — but proven against a
4// REAL, deployed emulate MCP server instead of an in-process stub.
5//
6// The `@executor-js/emulate` GitHub MCP emulator exposes a `scope-discovery`
7// instance whose RFC 9728 protected-resource metadata is deliberately SILENT on
8// scopes, forcing a spec-faithful client to fall back to the RFC 8414
9// authorization-server metadata it names — which advertises the scopes below.
10// The selfhost dev server fetches that live metadata over the network and the
11// returned authorize URL carries exactly the discovered scopes.
12import { randomBytes } from "node:crypto";
13
14import { expect } from "@effect/vitest";
15import { Effect } from "effect";
16import { composePluginApi } from "@executor-js/api/server";
17import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
18import {
19 AuthTemplateSlug,
20 ConnectionName,
21 IntegrationSlug,
22 OAuthClientSlug,
23} from "@executor-js/sdk/shared";
24import { scopesFromAuthorizeUrl } from "@executor-js/sdk/testing";
25
26import { scenario } from "../src/scenario";
27import { Api, Target } from "../src/services";
28
29const api = composePluginApi([mcpHttpPlugin()] as const);
30
31// The deployed emulate instance whose protected-resource metadata is silent on
32// scopes (it serves the MCP transport AND its own OAuth authorize/token + the
33// RFC 9728/8414 metadata). The `scope-discovery` preset configures it; no seed
34// call is needed. Use the path form — a two-label instance subdomain has no
35// Universal SSL cert.
36const EMULATOR_BASE = "https://emulators.dev/github/scope-discovery";
37const MCP_ENDPOINT = `${EMULATOR_BASE}/mcp`;
38
39// The scopes the emulate server advertises, only in its authorization-server
40// metadata (the protected-resource metadata stays silent). Discovering them
41// proves executor followed the RFC 8414 fallback, not a declared set.
42const DISCOVERED_SCOPES = ["channels:history", "users:read"] as const;
43
44const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`;
45
46scenario(
47 "MCP OAuth · oauth.start discovers scopes from a deployed emulate MCP server",
48 {},
49 Effect.scoped(
50 Effect.gen(function* () {
51 const target = yield* Target;
52 const { client: makeApiClient } = yield* Api;
53
54 const identity = yield* target.newIdentity();
55 const client = yield* makeApiClient(api, identity);
56 const slug = freshSlug("mcp-scope-emulate");
57
58 // An MCP integration pointed at the deployed emulate server — no scopes
59 // declared on the integration; the server is the source.
60 yield* client.mcp.addServer({
61 payload: {
62 transport: "remote",
63 name: "Scope-discovery MCP (emulate)",
64 endpoint: MCP_ENDPOINT,
65 slug,
66 authenticationTemplate: [{ kind: "oauth2" }],
67 },
68 });
69 yield* Effect.addFinalizer(() =>
70 client.mcp
71 .removeServer({ params: { slug: IntegrationSlug.make(slug) } })
72 .pipe(Effect.ignore),
73 );
74
75 // An OAuth app bound to the emulate MCP server as its resource (RFC 8707),
76 // using the emulate instance's own authorize/token endpoints. The app
77 // carries no scope set of its own.
78 const clientSlug = OAuthClientSlug.make(freshSlug("mcp-scope-emulate-app"));
79 yield* client.oauth.createClient({
80 payload: {
81 owner: "org",
82 slug: clientSlug,
83 authorizationUrl: `${EMULATOR_BASE}/authorize`,
84 tokenUrl: `${EMULATOR_BASE}/token`,
85 grant: "authorization_code",
86 clientId: "test-client",
87 clientSecret: "test-secret",
88 resource: MCP_ENDPOINT,
89 },
90 });
91 // `removeServer` only reaps DCR-shaped clients, so this manually-created
92 // app must be removed explicitly.
93 yield* Effect.addFinalizer(() =>
94 client.oauth
95 .removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } })
96 .pipe(Effect.ignore),
97 );
98
99 const started = yield* client.oauth.start({
100 payload: {
101 owner: "org",
102 client: clientSlug,
103 clientOwner: "org",
104 name: ConnectionName.make("main"),
105 integration: IntegrationSlug.make(slug),
106 template: AuthTemplateSlug.make("oauth2"),
107 },
108 });
109
110 expect(started.status, "start hands back an authorize redirect").toBe("redirect");
111 if (started.status !== "redirect") return;
112 // The redirect opened an in-flight OAuth session; drop it so the run leaves
113 // no pending session behind.
114 yield* Effect.addFinalizer(() =>
115 client.oauth.cancel({ payload: { state: started.state } }).pipe(Effect.ignore),
116 );
117
118 // The selfhost server fetched the deployed emulate server's metadata —
119 // silent protected resource, then its authorization server — and requested
120 // exactly the scopes it advertised.
121 expect(
122 scopesFromAuthorizeUrl(started.authorizationUrl),
123 "the authorize URL carries exactly the scopes the deployed emulate MCP server advertises",
124 ).toEqual([...DISCOVERED_SCOPES]);
125 }),
126 ),
127);
e2e/selfhost/mcp-oauth-scope-discovery.test.ts+123−0
@@ -0,0 +1,123 @@
1// Selfhost-only: an MCP integration declares NO oauth scopes — the server does.
2// At connect, `oauth.start` DISCOVERS the request scopes from the MCP server's
3// RFC 9728 protected-resource metadata and asks for exactly those on the
4// authorize URL. Proven end-to-end: a real MCP server (in this process)
5// publishes `scopes_supported`; the selfhost dev server fetches it over loopback
6// and the returned authorize URL carries the discovered scopes — not a declared
7// set and not the OAuth app's own scopes (the app declares none).
8import { randomBytes } from "node:crypto";
9
10import { expect } from "@effect/vitest";
11import { Effect } from "effect";
12import { composePluginApi } from "@executor-js/api/server";
13import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
14import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
15import {
16 AuthTemplateSlug,
17 ConnectionName,
18 IntegrationSlug,
19 OAuthClientSlug,
20} from "@executor-js/sdk/shared";
21import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "@executor-js/sdk/testing";
22
23import { scenario } from "../src/scenario";
24import { Api, Target } from "../src/services";
25
26const api = composePluginApi([mcpHttpPlugin()] as const);
27
28// The scopes the MCP server advertises in its protected-resource metadata.
29const DISCOVERED_SCOPES = ["channels:history", "users:read"] as const;
30
31const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`;
32
33scenario(
34 "MCP OAuth · oauth.start requests the scopes the server advertises, not a declared set",
35 {},
36 Effect.scoped(
37 Effect.gen(function* () {
38 const target = yield* Target;
39 const { client: makeApiClient } = yield* Api;
40
41 // A real authorization server (endpoints for the OAuth app) and a real MCP
42 // server whose RFC 9728 metadata advertises `scopes_supported`. The MCP
43 // integration declares NO template scopes — the server is the source.
44 const oauth = yield* serveOAuthTestServer();
45 const mcp = yield* serveMcpServer(() => makeGreetingMcpServer(), {
46 auth: {
47 validateAuthorization: () => Effect.succeed(true),
48 authorizationServerUrls: [oauth.issuerUrl],
49 scopes: [...DISCOVERED_SCOPES],
50 },
51 });
52
53 const identity = yield* target.newIdentity();
54 const client = yield* makeApiClient(api, identity);
55 const slug = freshSlug("mcp-scope-discovery");
56
57 // An MCP integration with a single OAuth method — no scopes declared.
58 yield* client.mcp.addServer({
59 payload: {
60 transport: "remote",
61 name: "Scope-discovery MCP",
62 endpoint: mcp.endpoint,
63 slug,
64 authenticationTemplate: [{ kind: "oauth2" }],
65 },
66 });
67 yield* Effect.addFinalizer(() =>
68 client.mcp
69 .removeServer({ params: { slug: IntegrationSlug.make(slug) } })
70 .pipe(Effect.ignore),
71 );
72
73 // An OAuth app bound to the MCP server as its resource (RFC 8707). The app
74 // carries no scope set of its own.
75 const clientSlug = OAuthClientSlug.make(freshSlug("mcp-scope-app"));
76 yield* client.oauth.createClient({
77 payload: {
78 owner: "org",
79 slug: clientSlug,
80 authorizationUrl: oauth.authorizationEndpoint,
81 tokenUrl: oauth.tokenEndpoint,
82 grant: "authorization_code",
83 clientId: "test-client",
84 clientSecret: "test-secret",
85 resource: mcp.endpoint,
86 },
87 });
88 // Best-effort cleanup: `removeServer` only reaps DCR-shaped clients, so this
89 // manually-created app must be removed explicitly.
90 yield* Effect.addFinalizer(() =>
91 client.oauth
92 .removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } })
93 .pipe(Effect.ignore),
94 );
95
96 const started = yield* client.oauth.start({
97 payload: {
98 owner: "org",
99 client: clientSlug,
100 clientOwner: "org",
101 name: ConnectionName.make("main"),
102 integration: IntegrationSlug.make(slug),
103 template: AuthTemplateSlug.make("oauth2"),
104 },
105 });
106
107 expect(started.status, "start hands back an authorize redirect").toBe("redirect");
108 if (started.status !== "redirect") return;
109 // The redirect opened an in-flight OAuth session; drop it so the run leaves
110 // no pending session behind.
111 yield* Effect.addFinalizer(() =>
112 client.oauth.cancel({ payload: { state: started.state } }).pipe(Effect.ignore),
113 );
114
115 // The selfhost server fetched the MCP server's protected-resource metadata
116 // and requested exactly the scopes it advertised.
117 expect(
118 scopesFromAuthorizeUrl(started.authorizationUrl),
119 "the authorize URL carries exactly the scopes the MCP server advertised",
120 ).toEqual([...DISCOVERED_SCOPES]);
121 }),
122 ),
123);
packages/core/sdk/src/executor.ts+25−17
@@ -85,7 +85,11 @@ import type { · 84 unmodified lines
85 IntegrationConfig,85 IntegrationConfig,
86 RegisterIntegrationInput,86 RegisterIntegrationInput,
87} from "./integration";87} from "./integration";
88import { makeOAuthService, type MintOAuthConnectionInput } from "./oauth-service";88import {
89 makeOAuthService,
90 type MintOAuthConnectionInput,
91 type OAuthScopePolicy,
92} from "./oauth-service";
89import type { OAuthService } from "./oauth-client";93import type { OAuthService } from "./oauth-client";
90import {94import {
91 comparePolicyRow,95 comparePolicyRow,
@@ -3192,23 +3196,27 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3100 unmodified lines
3192 ownedKeys: (owner: Owner) => ownedKeys(owner),3196 ownedKeys: (owner: Owner) => ownedKeys(owner),
3193 defaultWritableProvider,3197 defaultWritableProvider,
3194 mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input),3198 mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input),
3195 // Resolve the integration's DECLARED oauth scopes for a (integration,3199 // One integration-row read + one projector run. Resolve the method this
3196 // template): load the row, run the owning plugin's auth-method projector,3200 // template selects exactly as the runtime's `selectAuthMethod` does —
3197 // and return the matching oauth method's declared `oauth.scopes`. Drives3201 // exact slug match, else the sole declared method (single-method
3198 // the union-with-client-scopes request in `oauth.start` so reusing a narrow3202 // integrations accept any slug); an ambiguous miss selects nothing rather
3199 // client on a broad integration still requests the integration's full3203 // than guessing across methods. The discover-vs-scopes choice then reads
3200 // scope set. Empty (no row / no oauth method / no declared scopes) ⇒ the3204 // off that method (MCP exposes `discoveryUrl`), so core needs no plugin-id
3201 // union collapses to the client's scopes (current behavior).3205 // knowledge.
3202 resolveDeclaredOAuthScopes: (integration: IntegrationSlug, template: AuthTemplateSlug) =>3206 resolveOAuthScopePolicy: (integration: IntegrationSlug, template: AuthTemplateSlug) =>
3203 findIntegrationRow(integration).pipe(3207 findIntegrationRow(integration).pipe(
3204 Effect.map((row): readonly string[] => {3208 Effect.map((row): OAuthScopePolicy => {
3205 if (!row) return [];3209 const methods = row ? describeAuthMethodsForRow(row) : [];
3206 const methods = describeAuthMethodsForRow(row);3210 const selected =
3207 const match =3211 methods.find((m: AuthMethodDescriptor) => m.template === String(template)) ??
3208 methods.find(3212 (methods.length === 1 ? methods[0] : undefined);
3209 (m: AuthMethodDescriptor) => m.kind === "oauth" && m.template === String(template),3213 const oauth = selected?.kind === "oauth" ? selected.oauth : undefined;
3210 ) ?? methods.find((m: AuthMethodDescriptor) => m.kind === "oauth");3214 // Declared scopes win. Discover only when the selected method
3211 return match?.oauth?.scopes ?? [];3215 // declares none but names a source to discover them from (MCP).
3216 if (oauth?.scopes === undefined && oauth?.discoveryUrl !== undefined) {
3217 return { kind: "discover" };
3218 }
3219 return { kind: "scopes", scopes: oauth?.scopes ?? [] };
3212 }),3220 }),
3213 ),3221 ),
3214 httpClientLayer: config.httpClientLayer,3222 httpClientLayer: config.httpClientLayer,
packages/core/sdk/src/oauth-scope-union.test.ts+431−19
@@ -1,6 +1,6 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import { Context, Effect, Layer, Predicate, type Scope } from "effect";3import { Context, Effect, Exit, Layer, Predicate, type Scope } from "effect";
4import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";4import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
5 5
6import {6import {
@@ -13,12 +13,15 @@ import { · 6 unmodified lines
13import type { AuthMethodDescriptor } from "./integration";13import type { AuthMethodDescriptor } from "./integration";
14import { definePlugin, type IntegrationRecord } from "./plugin";14import { definePlugin, type IntegrationRecord } from "./plugin";
15import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";15import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
16import { serveOAuthTestServer } from "./testing/oauth-test-server";16import { serveTestHttpApp } from "./testing";
17import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server";
17 18
18// Integration-driven scopes: at connect, `oauth.start` requests EXACTLY the19// Integration-driven scopes: at connect, `oauth.start` requests the integration's
19// integration's DECLARED oauth scopes. The OAuth app carries no scope set of its20// scopes — its DECLARED oauth scopes when it has any, otherwise the scopes
20// own, so there is no union and no over-request — the integration is the sole21// DISCOVERED from the server's RFC 9728 / RFC 8414 metadata (MCP-style). Either
21// source of what to request. (Replaces the earlier declared∪client union model.)22// way the OAuth app carries no scope set of its own, so there is no union and no
23// over-request — the integration is the sole source of what to request. (Replaces
24// the earlier declared∪client union model.)
22 25
23const INTEG = IntegrationSlug.make("acme");26const INTEG = IntegrationSlug.make("acme");
24const TEMPLATE = AuthTemplateSlug.make("oauth");27const TEMPLATE = AuthTemplateSlug.make("oauth");
@@ -29,11 +32,15 @@ const DECLARED_SCOPES = ["calendar", "gmail", "drive", "sheets"] as const; · 4 unmodified lines
29 32
30/** A plugin whose integration config carries declared oauth scopes, projected33/** A plugin whose integration config carries declared oauth scopes, projected
31 * into an oauth `AuthMethodDescriptor` via `describeAuthMethods` — exactly the34 * into an oauth `AuthMethodDescriptor` via `describeAuthMethods` — exactly the
32 * shape `resolveDeclaredOAuthScopes` reads. `scopes: null` ⇒ no declared oauth35 * shape `resolveOAuthScopePolicy` reads. `scopes: null` ⇒ no declared oauth
33 * scopes (the MCP/no-template-scopes case). */36 * scopes (the MCP/no-template-scopes case). */
34const makeScopePlugin = (config: { readonly scopes: readonly string[] | null }) =>37const makeScopePluginWithId = <const TId extends string>(
38 id: TId,
39 config: { readonly scopes: readonly string[] | null },
40 options: { readonly discoversScopes?: boolean } = {},
41) =>
35 definePlugin(() => ({42 definePlugin(() => ({
36 id: "acme" as const,43 id,
37 storage: () => ({}),44 storage: () => ({}),
38 resolveTools: () =>45 resolveTools: () =>
39 Effect.succeed({46 Effect.succeed({
@@ -44,9 +51,20 @@ const makeScopePlugin = (config: { readonly scopes: readonly string[] | null }) · 4 unmodified lines
44 const cfg = record.config as { readonly scopes?: readonly string[] | null } | null;51 const cfg = record.config as { readonly scopes?: readonly string[] | null } | null;
45 const scopes = cfg?.scopes;52 const scopes = cfg?.scopes;
46 if (scopes == null) {53 if (scopes == null) {
47 // No declared oauth scopes — the integration declares an oauth method54 // No declared oauth scopes. Server-targeting methods (MCP) expose a
48 // with no template scopes (MCP/DCR-style). Declared scopes resolve to [].55 // `discoveryUrl` and discover scopes from the server at connect; others
49 return [{ id: "oauth", label: "OAuth2", kind: "oauth", template: String(TEMPLATE) }];56 // declare none. Declared scopes resolve to [].
57 return [
58 {
59 id: "oauth",
60 label: "OAuth2",
61 kind: "oauth",
62 template: String(TEMPLATE),
63 ...(options.discoversScopes
64 ? { oauth: { discoveryUrl: `https://${id}.example/mcp` } }
65 : {}),
66 },
67 ];
50 }68 }
51 return [69 return [
52 {70 {
@@ -68,11 +86,97 @@ const makeScopePlugin = (config: { readonly scopes: readonly string[] | null }) · 15 unmodified lines
68 }),86 }),
69 }))();87 }))();
70 88
71/** Parse the `scope` query param from an authorize URL into an ordered list. */89const makeScopePlugin = (config: { readonly scopes: readonly string[] | null }) =>
72const scopesFromAuthorizeUrl = (authorizationUrl: string): readonly string[] => {90 makeScopePluginWithId("acme", config);
73 const raw = new URL(authorizationUrl).searchParams.get("scope");91
74 return raw == null || raw.length === 0 ? [] : raw.split(" ");92const makeMcpScopePlugin = (config: { readonly scopes: readonly string[] | null }) =>
75};93 makeScopePluginWithId("mcp", config, { discoversScopes: true });
94
95/** Serve RFC 9728 protected-resource metadata and RFC 8414 authorization-server
96 * metadata on one origin, with configurable scopes. `prm`: an object serves
97 * protected-resource metadata (omit `scopesSupported` to leave the resource
98 * SILENT on scopes; `[]` advertises an explicit empty set); `"error"` makes the
99 * PRM endpoint return 500; `null`/absent makes it 404. `authServerScopes`, when
100 * set, serves authorization-server metadata advertising those scopes. */
101const serveMetadataServer = (config: {
102 readonly prm?:
103 | {
104 readonly authorizationServers?: readonly string[];
105 readonly scopesSupported?: readonly string[];
106 }
107 | "error"
108 | null;
109 readonly authServerScopes?: readonly string[];
110}) =>
111 Effect.gen(function* () {
112 const baseUrlRef = { value: "" };
113 const respond = (path: string): HttpServerResponse.HttpServerResponse => {
114 const base = baseUrlRef.value;
115 if (path.startsWith("/.well-known/oauth-protected-resource")) {
116 if (config.prm === "error")
117 return HttpServerResponse.jsonUnsafe({ error: "boom" }, { status: 500 });
118 if (config.prm == null) return HttpServerResponse.empty({ status: 404 });
119 return HttpServerResponse.jsonUnsafe({
120 resource: `${base}/mcp`,
121 bearer_methods_supported: ["header"],
122 ...(config.prm.authorizationServers
123 ? { authorization_servers: config.prm.authorizationServers }
124 : {}),
125 ...(config.prm.scopesSupported !== undefined
126 ? { scopes_supported: config.prm.scopesSupported }
127 : {}),
128 });
129 }
130 if (
131 path === "/.well-known/oauth-authorization-server" &&
132 config.authServerScopes !== undefined
133 ) {
134 return HttpServerResponse.jsonUnsafe({
135 issuer: base,
136 authorization_endpoint: `${base}/authorize`,
137 token_endpoint: `${base}/token`,
138 response_types_supported: ["code"],
139 code_challenge_methods_supported: ["S256"],
140 scopes_supported: config.authServerScopes,
141 });
142 }
143 return HttpServerResponse.empty({ status: 404 });
144 };
145 const server = yield* serveTestHttpApp((request) => Effect.succeed(respond(request.url)));
146 baseUrlRef.value = server.baseUrl;
147 return {
148 baseUrl: server.baseUrl,
149 authorizationEndpoint: server.url("/authorize"),
150 tokenEndpoint: server.url("/token"),
151 mcpResourceUrl: server.url("/mcp"),
152 };
153 });
154
155/** Seed an MCP integration with no declared scopes and an OAuth app bound to the
156 * given server as its resource, returning the executor ready to `oauth.start`.
157 * The shared setup for the discovery cases below; case (h) inlines its own (no
158 * `resource`) because the absent resource IS the case under test. */
159const setupMcpScopeClient = (server: {
160 readonly authorizationEndpoint: string;
161 readonly tokenEndpoint: string;
162 readonly mcpResourceUrl: string;
163}) =>
164 Effect.gen(function* () {
165 const plugins = [memoryCredentialsPlugin(), makeMcpScopePlugin({ scopes: null })] as const;
166 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
167 yield* executor.mcp.seed();
168 yield* executor.oauth.createClient({
169 owner: "org",
170 slug: CLIENT,
171 authorizationUrl: server.authorizationEndpoint,
172 tokenUrl: server.tokenEndpoint,
173 grant: "authorization_code",
174 clientId: "test-client",
175 clientSecret: "test-secret",
176 resource: server.mcpResourceUrl,
177 });
178 return executor;
179 });
76 180
77describe("oauth.start integration-driven scopes", () => {181describe("oauth.start integration-driven scopes", () => {
78 it.effect(182 it.effect(
@@ -151,10 +255,265 @@ describe("oauth.start integration-driven scopes", () => { · 72 unmodified lines
151 }),255 }),
152 ),256 ),
153 );257 );
258
259 it.effect(
260 "(c) for MCP with no protected-resource metadata, start requests no scopes (no arbitrary issuer probing)",
261 () =>
262 Effect.scoped(
263 Effect.gen(function* () {
264 // The server advertises authorization-server scopes but publishes no
265 // RFC 9728 protected-resource metadata. We only follow the
266 // authorization servers a resource NAMES, so with no PRM there is
267 // nothing to follow — start requests no scopes rather than probing the
268 // resource URL or its origin.
269 const server = yield* serveMetadataServer({
270 authServerScopes: ["channels:history", "users:read"],
271 });
272 const executor = yield* setupMcpScopeClient(server);
273
274 const started = yield* executor.oauth.start({
275 owner: "org",
276 client: CLIENT,
277 clientOwner: "org",
278 name: ConnectionName.make("main"),
279 integration: INTEG,
280 template: TEMPLATE,
281 });
282 expect(started.status).toBe("redirect");
283 if (started.status !== "redirect") return;
284
285 expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([]);
286 }),
287 ),
288 );
289
290 it.effect(
291 "(d) for MCP, the resource's own scopes_supported wins over a divergent authorization server",
292 () =>
293 Effect.scoped(
294 Effect.gen(function* () {
295 // The resource publishes its own RFC 9728 `scopes_supported` AND names
296 // an AS advertising a DIFFERENT set. The resource's list is
297 // authoritative (RFC 9728 §7.2): start requests exactly it and never
298 // consults the AS. This is the primary positive discovery path.
299 const server = yield* serveMetadataServer({
300 prm: { scopesSupported: ["channels:history", "users:read"] },
301 authServerScopes: ["should:not:appear"],
302 });
303 const executor = yield* setupMcpScopeClient(server);
304
305 const started = yield* executor.oauth.start({
306 owner: "org",
307 client: CLIENT,
308 clientOwner: "org",
309 name: ConnectionName.make("main"),
310 integration: INTEG,
311 template: TEMPLATE,
312 });
313 expect(started.status).toBe("redirect");
314 if (started.status !== "redirect") return;
315
316 expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([
317 "channels:history",
318 "users:read",
319 ]);
320 }),
321 ),
322 );
323
324 it.effect(
325 "(e) for MCP, discovers scopes from a cross-origin authorization server named in resource metadata",
326 () =>
327 Effect.scoped(
328 Effect.gen(function* () {
329 // The resource's PRM is silent on scopes but names an AS on a DIFFERENT
330 // origin (RFC 9728). Discovery must follow `authorization_servers`, not
331 // just the resource URL/origin.
332 const authServer = yield* serveMetadataServer({
333 authServerScopes: ["read:cross", "write:cross"],
334 });
335 const resourceServer = yield* serveMetadataServer({
336 prm: { authorizationServers: [authServer.baseUrl] },
337 });
338 const executor = yield* setupMcpScopeClient(resourceServer);
339
340 const started = yield* executor.oauth.start({
341 owner: "org",
342 client: CLIENT,
343 clientOwner: "org",
344 name: ConnectionName.make("main"),
345 integration: INTEG,
346 template: TEMPLATE,
347 });
348 expect(started.status).toBe("redirect");
349 if (started.status !== "redirect") return;
350
351 expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([
352 "read:cross",
353 "write:cross",
354 ]);
355 }),
356 ),
357 );
358
359 it.effect(
360 "(f) for MCP, an explicit empty resource scopes_supported is honored (no AS fallback)",
361 () =>
362 Effect.scoped(
363 Effect.gen(function* () {
364 // The resource explicitly advertises NO scopes; the AS would offer some,
365 // but the resource's explicit empty set is authoritative.
366 const server = yield* serveMetadataServer({
367 prm: { scopesSupported: [] },
368 authServerScopes: ["should:not:appear"],
369 });
370 const executor = yield* setupMcpScopeClient(server);
371
372 const started = yield* executor.oauth.start({
373 owner: "org",
374 client: CLIENT,
375 clientOwner: "org",
376 name: ConnectionName.make("main"),
377 integration: INTEG,
378 template: TEMPLATE,
379 });
380 expect(started.status).toBe("redirect");
381 if (started.status !== "redirect") return;
382
383 expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([]);
384 }),
385 ),
386 );
387
388 it.effect(
389 "(g) for MCP, a resource-metadata discovery error fails start (no silent empty-scope fallback)",
390 () =>
391 Effect.scoped(
392 Effect.gen(function* () {
393 // A transient 500 on the PRM endpoint must surface, not be swallowed
394 // into "request no scopes".
395 const server = yield* serveMetadataServer({ prm: "error" });
396 const executor = yield* setupMcpScopeClient(server);
397
398 const exit = yield* Effect.exit(
399 executor.oauth.start({
400 owner: "org",
401 client: CLIENT,
402 clientOwner: "org",
403 name: ConnectionName.make("main"),
404 integration: INTEG,
405 template: TEMPLATE,
406 }),
407 );
408 expect(Exit.isFailure(exit)).toBe(true);
409 }),
410 ),
411 );
412
413 it.effect(
414 "(h) for MCP, a client with no resource fails start (discovery cannot run without one)",
415 () =>
416 Effect.scoped(
417 Effect.gen(function* () {
418 const server = yield* serveMetadataServer({ prm: { scopesSupported: ["read"] } });
419 const plugins = [
420 memoryCredentialsPlugin(),
421 makeMcpScopePlugin({ scopes: null }),
422 ] as const;
423 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
424 yield* executor.mcp.seed();
425
426 // No `resource` on the client — discovery has nothing to probe.
427 yield* executor.oauth.createClient({
428 owner: "org",
429 slug: CLIENT,
430 authorizationUrl: server.authorizationEndpoint,
431 tokenUrl: server.tokenEndpoint,
432 grant: "authorization_code",
433 clientId: "test-client",
434 clientSecret: "test-secret",
435 });
436
437 const exit = yield* Effect.exit(
438 executor.oauth.start({
439 owner: "org",
440 client: CLIENT,
441 clientOwner: "org",
442 name: ConnectionName.make("main"),
443 integration: INTEG,
444 template: TEMPLATE,
445 }),
446 );
447 expect(Exit.isFailure(exit)).toBe(true);
448 }),
449 ),
450 );
451
452 it.effect("(j) caps server-advertised resource scopes so the authorize URL stays bounded", () =>
453 Effect.scoped(
454 Effect.gen(function* () {
455 // A hostile/buggy server advertises far more scopes than any real
456 // template. Discovery caps the request at 100 so the authorize URL
457 // cannot be blown up.
458 const manyScopes = Array.from({ length: 200 }, (_, i) => `scope:${i}`);
459 const server = yield* serveMetadataServer({ prm: { scopesSupported: manyScopes } });
460 const executor = yield* setupMcpScopeClient(server);
461
462 const started = yield* executor.oauth.start({
463 owner: "org",
464 client: CLIENT,
465 clientOwner: "org",
466 name: ConnectionName.make("main"),
467 integration: INTEG,
468 template: TEMPLATE,
469 });
470 expect(started.status).toBe("redirect");
471 if (started.status !== "redirect") return;
472
473 const requested = scopesFromAuthorizeUrl(started.authorizationUrl);
474 expect(requested.length).toBe(100);
475 expect(requested).toEqual(manyScopes.slice(0, 100));
476 }),
477 ),
478 );
479
480 it.effect(
481 "(k) caps the named authorization-server probe list at 3 (a 4th named AS is never reached)",
482 () =>
483 Effect.scoped(
484 Effect.gen(function* () {
485 // The resource's PRM is silent on scopes and names four authorization
486 // servers. The first three contribute nothing; only the fourth would
487 // advertise scopes. The 3-server cap stops before it, so discovery
488 // yields no scopes — without the cap "capped:out" would appear.
489 const as1 = yield* serveMetadataServer({});
490 const as2 = yield* serveMetadataServer({});
491 const as3 = yield* serveMetadataServer({});
492 const as4 = yield* serveMetadataServer({ authServerScopes: ["capped:out"] });
493 const resourceServer = yield* serveMetadataServer({
494 prm: { authorizationServers: [as1.baseUrl, as2.baseUrl, as3.baseUrl, as4.baseUrl] },
495 });
496 const executor = yield* setupMcpScopeClient(resourceServer);
497
498 const started = yield* executor.oauth.start({
499 owner: "org",
500 client: CLIENT,
501 clientOwner: "org",
502 name: ConnectionName.make("main"),
503 integration: INTEG,
504 template: TEMPLATE,
505 });
506 expect(started.status).toBe("redirect");
507 if (started.status !== "redirect") return;
508
509 expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([]);
510 }),
511 ),
512 );
154});513});
155 514
156// -----------------------------------------------------------------------------515// -----------------------------------------------------------------------------
157// (c) The recorded `oauth_scope` reflects the requested (declared) scopes when516// (i) The recorded `oauth_scope` reflects the requested (declared) scopes when
158// the AS omits `scope`. A minimal inline token endpoint handles the517// the AS omits `scope`. A minimal inline token endpoint handles the
159// client_credentials grant and deliberately OMITS `scope` from its response,518// client_credentials grant and deliberately OMITS `scope` from its response,
160// forcing the recorded-scope fallback (`token.scope ?? requested.join(" ")`).519// forcing the recorded-scope fallback (`token.scope ?? requested.join(" ")`).
@@ -203,7 +562,7 @@ const serveScopelessTokenServer = (): Effect.Effect< · 42 unmodified lines
203 562
204describe("oauth.start recorded scope fallback", () => {563describe("oauth.start recorded scope fallback", () => {
205 it.effect(564 it.effect(
206 "(c) records the requested (declared) scopes when the authorization server omits scope",565 "(i) records the requested (declared) scopes when the authorization server omits scope",
207 () =>566 () =>
208 Effect.scoped(567 Effect.scoped(
209 Effect.gen(function* () {568 Effect.gen(function* () {
@@ -248,4 +607,57 @@ describe("oauth.start recorded scope fallback", () => { · 38 unmodified lines
248 }),607 }),
249 ),608 ),
250 );609 );
610
611 it.effect(
612 "(l) client_credentials discovers scopes and records them when the token endpoint omits scope",
613 () =>
614 Effect.scoped(
615 Effect.gen(function* () {
616 // Discover policy (MCP, no declared scopes) reached through the
617 // client_credentials grant: scopes come from the resource's PRM, and
618 // the scopeless token endpoint forces the recorded-scope fallback to
619 // the discovered set.
620 const metadataServer = yield* serveMetadataServer({
621 prm: { scopesSupported: ["mcp:read", "mcp:write"] },
622 });
623 const tokenServer = yield* serveScopelessTokenServer();
624 const plugins = [
625 memoryCredentialsPlugin(),
626 makeMcpScopePlugin({ scopes: null }),
627 ] as const;
628 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
629 yield* executor.mcp.seed();
630
631 yield* executor.oauth.createClient({
632 owner: "org",
633 slug: CLIENT,
634 authorizationUrl: "http://127.0.0.1/authorize",
635 tokenUrl: tokenServer.tokenEndpoint,
636 grant: "client_credentials",
637 clientId: "test-client",
638 clientSecret: "test-secret",
639 resource: metadataServer.mcpResourceUrl,
640 });
641
642 const started = yield* executor.oauth.start({
643 owner: "org",
644 client: CLIENT,
645 clientOwner: "org",
646 name: ConnectionName.make("cc"),
647 integration: INTEG,
648 template: TEMPLATE,
649 });
650 expect(started.status).toBe("connected");
651
652 // The discovered scopes are requested and (the token endpoint omitting
653 // `scope`) recorded on the connection.
654 const row = yield* Effect.promise(() =>
655 config.db.findFirst("connection", {
656 where: (b) => b("name", "=", "cc"),
657 }),
658 );
659 expect(row?.oauth_scope).toBe("mcp:read mcp:write");
660 }),
661 ),
662 );
251});663});
packages/core/sdk/src/oauth-service.ts+129−36
@@ -14,7 +14,7 @@ · 13 unmodified lines
14// redeems the session, exchanges the code, and mints the connection.14// redeems the session, exchanges the code, and mints the connection.
15// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
16 16
17import { Effect, Layer, Option, Schema } from "effect";17import { Duration, Effect, Layer, Option, Schema } from "effect";
18import { FetchHttpClient, type HttpClient } from "effect/unstable/http";18import { FetchHttpClient, type HttpClient } from "effect/unstable/http";
19 19
20import type { Connection } from "./connection";20import type { Connection } from "./connection";
@@ -52,6 +52,7 @@ import type { CredentialProvider } from "./provider"; · 31 unmodified lines
52import {52import {
53 discoverAuthorizationServerMetadata,53 discoverAuthorizationServerMetadata,
54 discoverProtectedResourceMetadata,54 discoverProtectedResourceMetadata,
55 OAuthDiscoveryError,
55 registerDynamicClient as registerDynamicClientDcr,56 registerDynamicClient as registerDynamicClientDcr,
56} from "./oauth-discovery";57} from "./oauth-discovery";
57import {58import {
@@ -94,6 +95,15 @@ export interface MintOAuthConnectionInput { · 36 unmodified lines
94 readonly oauthTokenUrl?: string | null;95 readonly oauthTokenUrl?: string | null;
95}96}
96 97
98/** The OAuth scope policy for a `(integration, template)`. Either the
99 * integration declares the scopes to request (`scopes`, possibly empty — an
100 * empty set requests no scopes), or it declares none and the request scopes
101 * are discovered from the server's metadata at connect (`discover`, used by
102 * MCP). The two are mutually exclusive by construction. */
103export type OAuthScopePolicy =
104 | { readonly kind: "scopes"; readonly scopes: readonly string[] }
105 | { readonly kind: "discover" };
106
97/** Everything the OAuth service needs from the executor: fuma access for the107/** Everything the OAuth service needs from the executor: fuma access for the
98 * owned `oauth_client` / `oauth_session` tables, the default credential108 * owned `oauth_client` / `oauth_session` tables, the default credential
99 * provider for minted tokens, a `mintOAuthConnection` callback (writes the109 * provider for minted tokens, a `mintOAuthConnection` callback (writes the
@@ -114,23 +124,20 @@ export interface OAuthServiceDeps { · 14 unmodified lines
114 input: MintOAuthConnectionInput,124 input: MintOAuthConnectionInput,
115 ) => Effect.Effect<Connection, StorageFailure>;125 ) => Effect.Effect<Connection, StorageFailure>;
116 /**126 /**
117 * Resolve the integration's DECLARED OAuth scopes for a given127 * Resolve the OAuth scope policy for a `(integration, template)`:
118 * `(integration, template)` — the scopes the integration's auth template128 * - `{ kind: "scopes", scopes }`: the scopes the integration's auth template
119 * advertises (e.g. an OpenAPI bundle's full authentication-template scope129 * DECLARES (e.g. an OpenAPI bundle's authentication-template scope union),
120 * union), NOT the scopes frozen on a specific `oauth_client` row.130 * NOT the scopes frozen on a specific `oauth_client` row. These are
121 *131 * requested verbatim at connect (`start`); an empty set requests none.
122 * At connect (`start`) the requested scope set is the UNION of these declared132 * - `{ kind: "discover" }`: the integration declares no scopes, so `start`
123 * scopes and the client's configured scopes, so reusing a narrow client on a133 * discovers the request scopes from the server's RFC 9728 / RFC 8414
124 * broad integration still requests the integration's full scope set. When the134 * metadata. Used by server-targeting integrations (MCP) whose scopes live
125 * integration declares no OAuth scopes (MCP / DCR integrations discover scopes135 * on the server rather than in a template.
126 * from the server; integrations with no declared template scopes) this returns
127 * `[]` and the union collapses to the client's scopes — current behavior,
128 * unchanged.
129 */136 */
130 readonly resolveDeclaredOAuthScopes: (137 readonly resolveOAuthScopePolicy: (
131 integration: IntegrationSlug,138 integration: IntegrationSlug,
132 template: AuthTemplateSlug,139 template: AuthTemplateSlug,
133 ) => Effect.Effect<readonly string[], StorageFailure>;140 ) => Effect.Effect<OAuthScopePolicy, StorageFailure>;
134 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;141 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
135 readonly fetch?: typeof globalThis.fetch;142 readonly fetch?: typeof globalThis.fetch;
136 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;143 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
@@ -169,9 +176,7 @@ const accessItemId = (owner: Owner, integration: IntegrationSlug, name: Connecti · 32 unmodified lines
169 `oauth:${owner}:${integration}:${name}`;176 `oauth:${owner}:${integration}:${name}`;
170const refreshItemIdFor = (accessId: string): string => `${accessId}:refresh`;177const refreshItemIdFor = (accessId: string): string => `${accessId}:refresh`;
171 178
172/** Order-preserving de-duplication of a scope list. The requested scope set is179/** Order-preserving de-duplication of a scope list. */
173 * the integration's DECLARED scopes — the integration is the sole source of what
174 * to request; the OAuth app no longer carries a scope set. */
175const dedupeScopes = (scopes: readonly string[]): readonly string[] => [...new Set(scopes)];180const dedupeScopes = (scopes: readonly string[]): readonly string[] => [...new Set(scopes)];
176 181
177const recordedOAuthScope = (182const recordedOAuthScope = (
@@ -349,6 +354,76 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 171 unmodified lines
349 // callback; redirect-requiring flows fail loudly via `requireRedirectUri`.354 // callback; redirect-requiring flows fail loudly via `requireRedirectUri`.
350 const redirectUri = deps.redirectUri;355 const redirectUri = deps.redirectUri;
351 356
357 // Caps on server-controlled discovery input — a hostile or buggy server must
358 // not be able to hang `oauth.start` or overflow the authorize URL.
359 const MAX_DISCOVERY_AUTH_SERVERS = 3; // AS-failover lists are tiny in practice
360 const MAX_DISCOVERED_SCOPES = 100; // far beyond any realistic authorization template
361 const capScopes = (scopes: readonly string[]): readonly string[] =>
362 dedupeScopes(scopes).slice(0, MAX_DISCOVERED_SCOPES);
363
364 // Discover the scopes to request when the integration declares none — only
365 // reached for integrations that opt in (MCP-style). The resource's own RFC
366 // 9728 `scopes_supported` is authoritative when present, even when empty (§2
367 // defines the field; §7.2 cautions against requesting more than it lists).
368 // Only when the resource is SILENT do we read the scopes advertised by the
369 // authorization servers it NAMES (RFC 8414) — we never probe arbitrary URLs.
370 const discoverScopesForResource = (
371 resource: string | null,
372 ): Effect.Effect<readonly string[], OAuthDiscoveryError> =>
373 Effect.gen(function* () {
374 if (resource == null) {
375 return yield* new OAuthDiscoveryError({
376 message: "Cannot discover OAuth scopes: the client has no resource configured",
377 });
378 }
379 // `httpClientLayer` flows through `options` so discovery uses the host's
380 // configured client (discovery self-provides from `options.httpClientLayer`).
381 const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy, httpClientLayer };
382
383 const protectedResource = yield* discoverProtectedResourceMetadata(
384 resource,
385 discoveryOptions,
386 );
387 const resourceScopes = protectedResource?.metadata.scopes_supported;
388 if (resourceScopes !== undefined) return capScopes(resourceScopes);
389
390 // The resource is silent on scopes — read them from the authorization
391 // servers it names, in order. An advertised list is authoritative even
392 // when empty. Any AS we cannot read clean RFC 8414 metadata from —
393 // unreachable, 404, malformed, or issuer-mismatched — contributes nothing
394 // and we move on (mirroring the dynamic-registration discovery path); if
395 // none advertise scopes we request none and let the AS apply its defaults
396 // (RFC 8414 metadata is optional, so its absence is not a failure). The
397 // list is server-controlled, so cap how many of its hosts we probe.
398 for (const issuer of (protectedResource?.metadata.authorization_servers ?? []).slice(
399 0,
400 MAX_DISCOVERY_AUTH_SERVERS,
401 )) {
402 const authServer = yield* discoverAuthorizationServerMetadata(
403 issuer,
404 discoveryOptions,
405 ).pipe(Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null)));
406 const scopes = authServer?.metadata.scopes_supported;
407 if (scopes !== undefined) return capScopes(scopes);
408 }
409
410 return [];
411 }).pipe(
412 // Bound the whole sequence (PRM + up to MAX_DISCOVERY_AUTH_SERVERS AS
413 // fetches, each with its own request timeout). 30s is larger than a single
414 // request timeout so it bounds the sequence, not a slow-but-valid request.
415 Effect.timeoutOrElse({
416 duration: Duration.seconds(30),
417 orElse: () =>
418 Effect.fail(
419 new OAuthDiscoveryError({
420 message: "OAuth scope discovery timed out",
421 cause: "timeout",
422 }),
423 ),
424 }),
425 );
426
352 // -----------------------------------------------------------------------427 // -----------------------------------------------------------------------
353 // createClient — write the oauth_client row.428 // createClient — write the oauth_client row.
354 // -----------------------------------------------------------------------429 // -----------------------------------------------------------------------
@@ -656,22 +731,32 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 301 unmodified lines
656 });731 });
657 }732 }
658 733
659 // The INTEGRATION is the sole source of what to request — its declared auth734 // Declared scopes win (driven by the selected auth template). MCP-style
660 // scopes (driven by the integration's tools, surfaced via the declared auth735 // integrations declare none and discover them from the client's protected
661 // method). The OAuth app no longer carries a scope set, so there is no union736 // resource / authorization server metadata at connect.
662 // to compute and no way to over-request from a stale client copy.737 const scopePolicy = yield* deps
663 const declaredScopes = yield* deps738 .resolveOAuthScopePolicy(input.integration, input.template)
664 .resolveDeclaredOAuthScopes(input.integration, input.template)
665 .pipe(739 .pipe(
666 Effect.mapError(740 Effect.mapError(
667 (cause) =>741 (cause) =>
668 new OAuthStartError({742 new OAuthStartError({
669 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: StorageFailure carries a typed `message` field743 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: StorageFailure carries a typed `message` field
670 message: `Failed to resolve declared OAuth scopes: ${cause.message}`,744 message: `Failed to resolve OAuth scope policy: ${cause.message}`,
671 }),745 }),
672 ),746 ),
673 );747 );
674 const requestedScopes = dedupeScopes(declaredScopes);748 const requestedScopes =
749 scopePolicy.kind === "discover"
750 ? yield* discoverScopesForResource(client.resource).pipe(
751 Effect.mapError(
752 (cause) =>
753 new OAuthStartError({
754 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuthDiscoveryError carries a typed `message` field
755 message: `Failed to discover OAuth scopes: ${cause.message}`,
756 }),
757 ),
758 )
759 : dedupeScopes(scopePolicy.scopes);
675 760
676 // client_credentials: exchange immediately and mint the connection.761 // client_credentials: exchange immediately and mint the connection.
677 if (client.grant === "client_credentials") {762 if (client.grant === "client_credentials") {
@@ -746,10 +831,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 68 unmodified lines
746 redirect_url: flowRedirectUri,831 redirect_url: flowRedirectUri,
747 pkce_verifier: verifier,832 pkce_verifier: verifier,
748 identity_label: input.identityLabel ?? null,833 identity_label: input.identityLabel ?? null,
749 // Persist the requested scope set (declared ∪ client) so `complete`'s834 // Persist the requested scope set (the integration's declared or
750 // recorded-scope fallback reflects exactly what was requested when the835 // discovered scopes) so `complete`'s recorded-scope fallback reflects
751 // AS omits `scope`, without re-resolving the integration's declared836 // exactly what was requested when the AS omits `scope`, without
752 // scopes at completion.837 // re-resolving it at completion.
753 payload: { owner: input.owner, clientOwner: input.clientOwner, requestedScopes },838 payload: { owner: input.owner, clientOwner: input.clientOwner, requestedScopes },
754 expires_at: expiresAt,839 expires_at: expiresAt,
755 created_at: now,840 created_at: now,
@@ -807,9 +892,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 51 unmodified lines
807 pkceVerifier: sessionRow.pkce_verifier == null ? null : String(sessionRow.pkce_verifier),892 pkceVerifier: sessionRow.pkce_verifier == null ? null : String(sessionRow.pkce_verifier),
808 identityLabel: sessionRow.identity_label == null ? null : String(sessionRow.identity_label),893 identityLabel: sessionRow.identity_label == null ? null : String(sessionRow.identity_label),
809 expiresAt: Number(sessionRow.expires_at),894 expiresAt: Number(sessionRow.expires_at),
810 // The scope set `start` requested (declared ∪ client), persisted on the895 // The scope set `start` requested (the integration's declared or
811 // session payload. Drives the recorded-scope fallback when the AS omits896 // discovered scopes), persisted on the session payload. Drives the
812 // `scope`. Missing/legacy payloads fall back to the client's scopes below.897 // recorded-scope fallback when the AS omits `scope`. Missing/legacy
898 // payloads fall back to the client's scopes below.
813 requestedScopes: requestedScopesFromPayload(sessionRow.payload),899 requestedScopes: requestedScopesFromPayload(sessionRow.payload),
814 // The app's owner, recorded by `start` — reload the SAME app at900 // The app's owner, recorded by `start` — reload the SAME app at
815 // completion by explicit owner (no derivation). Defaults to the session901 // completion by explicit owner (no derivation). Defaults to the session
@@ -922,8 +1008,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 106 unmodified lines
922 },1008 },
923 client: LoadedOAuthClient,1009 client: LoadedOAuthClient,
924 token: OAuth2TokenResponse,1010 token: OAuth2TokenResponse,
925 /** The scope set requested at /authorize + /token (declared ∪ client) —1011 /** The scope set requested at /authorize + /token (the integration's
926 * the recorded-scope fallback when the AS omits `scope`. */1012 * declared or discovered scopes) — the recorded-scope fallback when the AS
1013 * omits `scope`. */
927 requestedScopes: readonly string[],1014 requestedScopes: readonly string[],
928 /** The owner of `client` — persisted so refresh loads it by explicit owner. */1015 /** The owner of `client` — persisted so refresh loads it by explicit owner. */
929 clientOwner: Owner,1016 clientOwner: Owner,
@@ -1019,7 +1106,13 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 89 unmodified lines
1019 authorizationUrl: as.metadata.authorization_endpoint,1106 authorizationUrl: as.metadata.authorization_endpoint,
1020 tokenUrl: as.metadata.token_endpoint,1107 tokenUrl: as.metadata.token_endpoint,
1021 resource: resource?.metadata.resource ?? null,1108 resource: resource?.metadata.resource ?? null,
1022 scopesSupported: as.metadata.scopes_supported,1109 // Prefer the resource's own RFC 9728 scopes (authoritative, even when
1110 // empty); fall back to the authorization server's list only when PRM is
1111 // silent. For a spec-compliant MCP server (one that publishes PRM) this
1112 // matches what `oauth.start` discovers. The AS fallback is a best-effort
1113 // hint for the registration form on servers that omit PRM — where
1114 // `oauth.start` requests none — so the two can differ for those.
1115 scopesSupported: resource?.metadata.scopes_supported ?? as.metadata.scopes_supported,
1023 registrationEndpoint: as.metadata.registration_endpoint ?? null,1116 registrationEndpoint: as.metadata.registration_endpoint ?? null,
1024 tokenEndpointAuthMethodsSupported: as.metadata.token_endpoint_auth_methods_supported,1117 tokenEndpointAuthMethodsSupported: as.metadata.token_endpoint_auth_methods_supported,
1025 } satisfies OAuthProbeResult;1118 } satisfies OAuthProbeResult;
packages/core/sdk/src/testing.ts+1−0
@@ -23,6 +23,7 @@ export { · 22 unmodified lines
23export {23export {
24 OAuthTestServer,24 OAuthTestServer,
25 serveOAuthTestServer,25 serveOAuthTestServer,
26 scopesFromAuthorizeUrl,
26 OAuthTestServerAddressError,27 OAuthTestServerAddressError,
27 OAuthTestServerFlowError,28 OAuthTestServerFlowError,
28 type OAuthAuthorizationCompletion,29 type OAuthAuthorizationCompletion,
packages/core/sdk/src/testing/oauth-test-server.ts+7−0
@@ -389,6 +389,13 @@ const completeAuthorizationCodeTokenFlow = · 388 unmodified lines
389 };389 };
390 });390 });
391 391
392/** Parse the `scope` query param from an authorize URL into an ordered list
393 * (empty when the parameter is absent or blank). */
394export const scopesFromAuthorizeUrl = (authorizationUrl: string): readonly string[] => {
395 const raw = new URL(authorizationUrl).searchParams.get("scope");
396 return raw == null || raw.length === 0 ? [] : raw.split(" ");
397};
398
392export const serveOAuthTestServer = (399export const serveOAuthTestServer = (
393 options: OAuthTestServerOptions = {},400 options: OAuthTestServerOptions = {},
394): Effect.Effect<OAuthTestServerShape, OAuthTestServerAddressError, Scope.Scope> =>401): Effect.Effect<OAuthTestServerShape, OAuthTestServerAddressError, Scope.Scope> =>
packages/plugins/mcp/src/react/auth-method-config.test.ts+9−3
@@ -13,7 +13,7 @@ describe("mcpAuthMethodInputFromEditorValue", () => { · 12 unmodified lines
13 expect(mcpAuthMethodInputFromEditorValue({ kind: "none" })).toEqual({ kind: "none" });13 expect(mcpAuthMethodInputFromEditorValue({ kind: "none" })).toEqual({ kind: "none" });
14 });14 });
15 15
16 it("maps 'oauth' → { kind: 'oauth2' } (endpoints are resolved at connect time)", () => {16 it("maps 'oauth' → { kind: 'oauth2' } (endpoints/scopes are resolved at connect time)", () => {
17 const value: AuthTemplateEditorValue = {17 const value: AuthTemplateEditorValue = {
18 kind: "oauth",18 kind: "oauth",
19 authorizationUrl: "https://a.example.com/auth",19 authorizationUrl: "https://a.example.com/auth",
@@ -108,8 +108,13 @@ describe("editorValueFromMcpAuthMethod", () => { · 88 unmodified lines
108 });108 });
109 });109 });
110 110
111 it("maps oauth2 to an oauth editor value with no endpoints (discovered at connect)", () => {111 it("maps oauth2 to an oauth editor value with no endpoints or scopes (discovered at connect)", () => {
112 expect(editorValueFromMcpAuthMethod({ slug: "oauth2", kind: "oauth2" })).toEqual({112 expect(
113 editorValueFromMcpAuthMethod({
114 slug: "oauth2",
115 kind: "oauth2",
116 }),
117 ).toEqual({
113 kind: "oauth",118 kind: "oauth",
114 authorizationUrl: "",119 authorizationUrl: "",
115 tokenUrl: "",120 tokenUrl: "",
@@ -146,6 +151,7 @@ describe("authMethodsFromConfig", () => { · 30 unmodified lines
146 { id: "none", kind: "none", source: "spec", template: "none" },151 { id: "none", kind: "none", source: "spec", template: "none" },
147 ]);152 ]);
148 expect(methods[0]?.oauth?.discoveryUrl).toBe("https://mcp.example.com/mcp");153 expect(methods[0]?.oauth?.discoveryUrl).toBe("https://mcp.example.com/mcp");
154 expect(methods[0]?.oauth?.scopes).toBeUndefined();
149 });155 });
150 156
151 it("carries multi-placement methods through to the hub", () => {157 it("carries multi-placement methods through to the hub", () => {
packages/plugins/mcp/src/sdk/plugin.test.ts+47−0
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
18import {18import {
19 makeTestConfig,19 makeTestConfig,
20 memoryCredentialsPlugin,20 memoryCredentialsPlugin,
21 scopesFromAuthorizeUrl,
21 serveOAuthTestServer,22 serveOAuthTestServer,
22 serveTestHttpApp,23 serveTestHttpApp,
23} from "@executor-js/sdk/testing";24} from "@executor-js/sdk/testing";
@@ -502,6 +503,52 @@ describe("mcpPlugin", () => { · 478 unmodified lines
502 }),503 }),
503 );504 );
504 505
506 it.effect("oauth.start discovers scopes for an MCP oauth method", () =>
507 Effect.scoped(
508 Effect.gen(function* () {
509 const server = yield* serveOAuthTestServer({
510 scopes: ["channels:history", "users:read"],
511 });
512 const executor = yield* createExecutor(
513 makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
514 );
515
516 yield* executor.mcp.addServer({
517 name: "Slack MCP",
518 endpoint: server.mcpResourceUrl,
519 slug: "slack_mcp",
520 authenticationTemplate: [{ kind: "oauth2" }],
521 });
522 yield* executor.oauth.createClient({
523 owner: "org",
524 slug: OAuthClientSlug.make("slack-app"),
525 authorizationUrl: server.authorizationEndpoint,
526 tokenUrl: server.tokenEndpoint,
527 grant: "authorization_code",
528 clientId: "test-client",
529 clientSecret: "test-secret",
530 resource: server.mcpResourceUrl,
531 });
532
533 const started = yield* executor.oauth.start({
534 owner: "org",
535 client: OAuthClientSlug.make("slack-app"),
536 clientOwner: "org",
537 name: ConnectionName.make("main"),
538 integration: IntegrationSlug.make("slack_mcp"),
539 template: AuthTemplateSlug.make("oauth2"),
540 });
541
542 expect(started.status).toBe("redirect");
543 if (started.status !== "redirect") return;
544 expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([
545 "channels:history",
546 "users:read",
547 ]);
548 }),
549 ),
550 );
551
505 // When discovery fails (auth, network, etc.) the connection still lands with552 // When discovery fails (auth, network, etc.) the connection still lands with
506 // an empty tool set so the user can retry via `connections.refresh` once they553 // an empty tool set so the user can retry via `connections.refresh` once they
507 // fix the underlying problem.554 // fix the underlying problem.
packages/plugins/mcp/src/sdk/plugin.ts+4−3
@@ -527,9 +527,10 @@ const buildConnectorInput = ( · 526 unmodified lines
527// stdio → [] (no remote connection to configure)527// stdio → [] (no remote connection to configure)
528// apikey → carried placements (headers / query params) verbatim528// apikey → carried placements (headers / query params) verbatim
529// oauth2 → an oauth method carrying the MCP endpoint to probe529// oauth2 → an oauth method carrying the MCP endpoint to probe
530// (`discoveryUrl`); endpoints are discovered live at530// (`discoveryUrl`). Endpoints/scopes are discovered
531// connect time, so they are NOT pre-resolved here. We531// live at connect time, so they are NOT pre-resolved
532// mark `supportsDynamicRegistration: true` because MCP532// here. We mark
533// `supportsDynamicRegistration: true` because MCP
533// OAuth servers are expected to support RFC 7591 DCR;534// OAuth servers are expected to support RFC 7591 DCR;
534// the connect flow probes to confirm and falls back.535// the connect flow probes to confirm and falls back.
535// ---------------------------------------------------------------------------536// ---------------------------------------------------------------------------
packages/react/src/components/add-account-modal.test.ts+50−2
@@ -278,6 +278,7 @@ describe("runDcrConnect", () => { · 277 unmodified lines
278 },278 },
279 {279 {
280 discoveryUrl: "https://mcp.example.com/mcp",280 discoveryUrl: "https://mcp.example.com/mcp",
281 resourceFallback: "https://mcp.example.com/mcp",
281 owner: "user",282 owner: "user",
282 integrationName: "App",283 integrationName: "App",
283 existingSlugs: [],284 existingSlugs: [],
@@ -287,6 +288,42 @@ describe("runDcrConnect", () => { · 3 unmodified lines
287 );288 );
288 expect(outcome.kind).toBe("started");289 expect(outcome.kind).toBe("started");
289 expect(registerArgs!.scopes).toEqual(["declared.scope"]);290 expect(registerArgs!.scopes).toEqual(["declared.scope"]);
291 // PRM named no `resource`, so the genuine discovery URL (MCP) seeds the
292 // RFC 8707 resource indicator.
293 expect(registerArgs!.resource).toBe("https://mcp.example.com/mcp");
294 });
295
296 it("never seeds the resource indicator from the token endpoint (non-MCP DCR)", async () => {
297 // A DCR integration with no discovery URL: `discoveryUrl` falls back to the
298 // token endpoint for probing, but the token endpoint is not an RFC 8707
299 // resource identifier, so it must NOT become the resource. With no genuine
300 // discovery URL and no PRM-named resource, the indicator stays null.
301 let registerArgs: RegisterArgs | null = null;
302 const outcome = await runDcrConnect(
303 {
304 probe: (): Promise<ProbeResult | null> =>
305 Promise.resolve({
306 authorizationUrl: "https://auth.example.com/authorize",
307 tokenUrl: "https://auth.example.com/token",
308 registrationEndpoint: "https://auth.example.com/register",
309 }),
310 register: (args: RegisterArgs): Promise<OAuthClientSlug | null> => {
311 registerArgs = args;
312 return Promise.resolve(OAuthClientSlug.make("app"));
313 },
314 start: (): void => {},
315 },
316 {
317 // discoveryUrl collapsed to the token endpoint (no real discovery URL).
318 discoveryUrl: "https://auth.example.com/token",
319 owner: "user",
320 integrationName: "App",
321 existingSlugs: [],
322 integration: TEST_INTEGRATION,
323 },
324 );
325 expect(outcome.kind).toBe("started");
326 expect(registerArgs!.resource).toBeNull();
290 });327 });
291 328
292 it("falls back to BYO when there is no registration endpoint (no register/start)", async () => {329 it("falls back to BYO when there is no registration endpoint (no register/start)", async () => {
@@ -317,9 +354,13 @@ describe("runDcrConnect", () => { · 24 unmodified lines
317 integration: TEST_INTEGRATION,354 integration: TEST_INTEGRATION,
318 },355 },
319 );356 );
320 expect(outcome).toEqual({357 expect(outcome).toMatchObject({
321 kind: "fallback",358 kind: "fallback",
322 reason: "no-registration-endpoint",359 reason: "no-registration-endpoint",
360 probe: {
361 authorizationUrl: "https://auth.example.com/authorize",
362 tokenUrl: "https://auth.example.com/token",
363 },
323 });364 });
324 expect(calls).toEqual(["probe"]);365 expect(calls).toEqual(["probe"]);
325 });366 });
@@ -378,7 +419,14 @@ describe("runDcrConnect", () => { · 52 unmodified lines
378 integration: TEST_INTEGRATION,419 integration: TEST_INTEGRATION,
379 },420 },
380 );421 );
381 expect(outcome.kind).toBe("fallback");422 expect(outcome).toMatchObject({
423 kind: "fallback",
424 reason: "registration-failed",
425 probe: {
426 authorizationUrl: "https://auth.example.com/authorize",
427 tokenUrl: "https://auth.example.com/token",
428 },
429 });
382 expect(calls).toEqual(["register"]);430 expect(calls).toEqual(["register"]);
383 });431 });
384});432});
packages/react/src/components/add-account-modal.tsx+64−24
@@ -48,7 +48,11 @@ import { · 47 unmodified lines
48} from "../plugins/use-effective-oauth-client";48} from "../plugins/use-effective-oauth-client";
49import { cn } from "../lib/utils";49import { cn } from "../lib/utils";
50import { buildUsageMap, connectionsUsingClient } from "../lib/oauth-client-usage";50import { buildUsageMap, connectionsUsingClient } from "../lib/oauth-client-usage";
51import { OAuthClientForm, type OAuthClientFormPrefill } from "./oauth-client-form";51import {
52 OAuthClientForm,
53 registrationScopes,
54 type OAuthClientFormPrefill,
55} from "./oauth-client-form";
52import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog";56import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog";
53import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal";57import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal";
54import { PlacementLine, type AuthMethod } from "../lib/auth-placements";58import { PlacementLine, type AuthMethod } from "../lib/auth-placements";
@@ -479,13 +483,17 @@ type DcrStartArgs = { · 424 unmodified lines
479 483
480/** Outcome of the DCR orchestration. `"started"` means the OAuth flow handed484/** Outcome of the DCR orchestration. `"started"` means the OAuth flow handed
481 * off (the popup/inline start ran); `"fallback"` means we could not auto-set-up485 * off (the popup/inline start ran); `"fallback"` means we could not auto-set-up
482 * (probe failed, or no registration endpoint) and the caller should fall back486 * — a failed probe, no registration endpoint, or a failed registration — and
483 * to the bring-your-own-app picker. */487 * the caller should fall back to the bring-your-own-app picker. A failed probe
488 * carries no probe result; the other two reasons always carry the probe that
489 * seeds the picker. */
484type DcrOutcome =490type DcrOutcome =
485 | { readonly kind: "started" }491 | { readonly kind: "started" }
492 | { readonly kind: "fallback"; readonly reason: "probe-failed" }
486 | {493 | {
487 readonly kind: "fallback";494 readonly kind: "fallback";
488 readonly reason: "probe-failed" | "no-registration-endpoint";495 readonly reason: "no-registration-endpoint" | "registration-failed";
496 readonly probe: DcrProbeResult;
489 };497 };
490 498
491type RunDcrConnectDeps = {499type RunDcrConnectDeps = {
@@ -500,6 +508,13 @@ type RunDcrConnectDeps = { · 8 unmodified lines
500 508
501type RunDcrConnectInput = {509type RunDcrConnectInput = {
502 readonly discoveryUrl: string;510 readonly discoveryUrl: string;
511 /** The integration's genuine protected-resource URL (the MCP discovery URL),
512 * used as the RFC 8707 resource indicator when the server's PRM names no
513 * `resource`. Distinct from `discoveryUrl`, which falls back to the token
514 * endpoint for probing: the token endpoint is NOT a resource identifier, so
515 * it must never become the indicator. Undefined for integrations with no
516 * discovery URL (non-MCP DCR), collapsing the resource to null as before. */
517 readonly resourceFallback?: string;
503 readonly owner: Owner;518 readonly owner: Owner;
504 readonly integrationName: string;519 readonly integrationName: string;
505 /** The owner's existing client slugs, so the minted slug stays unique. */520 /** The owner's existing client slugs, so the minted slug stays unique. */
@@ -521,9 +536,8 @@ export const dcrClientNameForIntegration = (integrationName: string): string => · 15 unmodified lines
521 * Run the transparent DCR connect sequence: probe → register → start.536 * Run the transparent DCR connect sequence: probe → register → start.
522 *537 *
523 * - Probe failure → `{ kind: "fallback", reason: "probe-failed" }` (caller shows BYO).538 * - Probe failure → `{ kind: "fallback", reason: "probe-failed" }` (caller shows BYO).
524 * - No registration endpoint → `{ kind: "fallback", reason: "no-registration-endpoint" }`.539 * - No registration endpoint → `{ kind: "fallback", reason: "no-registration-endpoint", probe }`.
525 * - Register failure → throws via the injected `register` rejecting; the caller540 * - Registration failure → `{ kind: "fallback", reason: "registration-failed", probe }`.
526 * treats a thrown/rejected register as fallback (kept out of the happy path).
527 * - Success → registers, calls `start`, returns `{ kind: "started" }`.541 * - Success → registers, calls `start`, returns `{ kind: "started" }`.
528 */542 */
529export async function runDcrConnect(543export async function runDcrConnect(
@@ -533,27 +547,24 @@ export async function runDcrConnect( · 3 unmodified lines
533 const probe = await deps.probe(input.discoveryUrl);547 const probe = await deps.probe(input.discoveryUrl);
534 if (probe === null) return { kind: "fallback", reason: "probe-failed" };548 if (probe === null) return { kind: "fallback", reason: "probe-failed" };
535 const registrationEndpoint = probe.registrationEndpoint;549 const registrationEndpoint = probe.registrationEndpoint;
536 if (!registrationEndpoint) return { kind: "fallback", reason: "no-registration-endpoint" };550 if (!registrationEndpoint) return { kind: "fallback", reason: "no-registration-endpoint", probe };
537 551
538 const slug = uniqueClientSlug(input.integrationName, input.existingSlugs);552 const slug = uniqueClientSlug(input.integrationName, input.existingSlugs);
539 const scopes =553 const scopes = registrationScopes(input.declaredScopes ?? [], probe.scopesSupported ?? []);
540 input.declaredScopes && input.declaredScopes.length > 0
541 ? input.declaredScopes
542 : (probe.scopesSupported ?? []);
543 const minted = await deps.register({554 const minted = await deps.register({
544 owner: input.owner,555 owner: input.owner,
545 slug,556 slug,
546 registrationEndpoint,557 registrationEndpoint,
547 authorizationUrl: probe.authorizationUrl,558 authorizationUrl: probe.authorizationUrl,
548 tokenUrl: probe.tokenUrl,559 tokenUrl: probe.tokenUrl,
549 resource: probe.resource ?? null,560 resource: probe.resource ?? input.resourceFallback ?? null,
550 scopes,561 scopes,
551 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,562 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
552 clientName: dcrClientNameForIntegration(input.integrationName),563 clientName: dcrClientNameForIntegration(input.integrationName),
553 redirectUri: input.redirectUri,564 redirectUri: input.redirectUri,
554 originIntegration: input.integration,565 originIntegration: input.integration,
555 });566 });
556 if (minted === null) return { kind: "fallback", reason: "probe-failed" };567 if (minted === null) return { kind: "fallback", reason: "registration-failed", probe };
557 deps.start({ client: minted, owner: input.owner });568 deps.start({ client: minted, owner: input.owner });
558 return { kind: "started" };569 return { kind: "started" };
559}570}
@@ -704,6 +715,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 144 unmodified lines
704 // the modal to the bring-your-own-app picker if auto setup is unavailable.715 // the modal to the bring-your-own-app picker if auto setup is unavailable.
705 const [dcrBusy, setDcrBusy] = useState(false);716 const [dcrBusy, setDcrBusy] = useState(false);
706 const [dcrFailed, setDcrFailed] = useState(false);717 const [dcrFailed, setDcrFailed] = useState(false);
718 const [oauthFallbackProbe, setOAuthFallbackProbe] = useState<DcrProbeResult | null>(null);
707 // FIX 3 escape hatch: when no registered app matched the integration's719 // FIX 3 escape hatch: when no registered app matched the integration's
708 // endpoints, the unmatched apps are collapsed behind an opt-in expander.720 // endpoints, the unmatched apps are collapsed behind an opt-in expander.
709 const [showOtherApps, setShowOtherApps] = useState(false);721 const [showOtherApps, setShowOtherApps] = useState(false);
@@ -768,6 +780,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 58 unmodified lines
768 setCredentialOrigin("paste");780 setCredentialOrigin("paste");
769 setOnePasswordItemId("");781 setOnePasswordItemId("");
770 setPickedApp(null);782 setPickedApp(null);
783 setOAuthFallbackProbe(null);
771 setDcrFailed(false);784 setDcrFailed(false);
772 }, [initialState, allMethods, defaultOwner, ownerOptions]);785 }, [initialState, allMethods, defaultOwner, ownerOptions]);
773 786
@@ -859,8 +872,14 @@ function AddAccountModalView(props: AddAccountModalProps) { · 85 unmodified lines
859 endpointMatched: oauthEndpointMatched,872 endpointMatched: oauthEndpointMatched,
860 displayRegisterCTA: oauthDisplayRegisterCTA,873 displayRegisterCTA: oauthDisplayRegisterCTA,
861 } = useOAuthClientsForIntegration({874 } = useOAuthClientsForIntegration({
862 tokenUrl: method?.oauth?.tokenUrl,875 // MCP integrations declare no endpoints up front; once DCR has probed the
863 authorizationUrl: method?.oauth?.authorizationUrl,876 // server we match registered apps against the DISCOVERED endpoints. With no
877 // endpoints to match (a server-targeting MCP integration), require an
878 // explicit match so we surface the register CTA instead of auto-selecting an
879 // unrelated provider's app.
880 tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,
881 authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,
882 requireEndpointMatch: isDcr,
864 });883 });
865 const oauthPopup = useOAuthPopupFlow({884 const oauthPopup = useOAuthPopupFlow({
866 popupName: "add-account-oauth",885 popupName: "add-account-oauth",
@@ -941,6 +960,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 74 unmodified lines
941 setValues({});960 setValues({});
942 setCredentialOrigin("paste");961 setCredentialOrigin("paste");
943 setOnePasswordItemId("");962 setOnePasswordItemId("");
963 setDcrFailed(false);
964 setOAuthFallbackProbe(null);
944 };965 };
945 966
946 // A just-created custom method joins the in-session list and is auto-selected967 // A just-created custom method joins the in-session list and is auto-selected
@@ -1111,8 +1132,9 @@ function AddAccountModalView(props: AddAccountModalProps) { · 164 unmodified lines
1111 };1132 };
1112 1133
1113 // Transparent DCR connect: probe → register → start, no app picker. On any1134 // Transparent DCR connect: probe → register → start, no app picker. On any
1114 // failure (probe error or no registration endpoint) we flip `dcrFailed` so the1135 // failure (probe error, no registration endpoint, or registration failure) we
1115 // bring-your-own-app picker renders as the recovery path with name/owner kept.1136 // flip `dcrFailed` so the bring-your-own-app picker renders as the recovery
1137 // path with name/owner kept.
1116 const handleDcrConnect = async () => {1138 const handleDcrConnect = async () => {
1117 const discoveryUrl = method?.oauth?.discoveryUrl ?? method?.oauth?.tokenUrl;1139 const discoveryUrl = method?.oauth?.discoveryUrl ?? method?.oauth?.tokenUrl;
1118 if (!method || !discoveryUrl) {1140 if (!method || !discoveryUrl) {
@@ -1172,6 +1194,10 @@ function AddAccountModalView(props: AddAccountModalProps) { · 53 unmodified lines
1172 },1194 },
1173 {1195 {
1174 discoveryUrl,1196 discoveryUrl,
1197 // Only a genuine discovery URL (MCP) seeds the RFC 8707 resource
1198 // indicator; the token-endpoint fallback baked into `discoveryUrl` must
1199 // not, so pass the un-collapsed method value here.
1200 resourceFallback: method.oauth?.discoveryUrl,
1175 owner: dcrOwner,1201 owner: dcrOwner,
1176 integrationName,1202 integrationName,
1177 existingSlugs: [...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>1203 existingSlugs: [...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>
@@ -1191,6 +1217,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 13 unmodified lines
1191 ...(outcome.kind === "fallback" ? { dcr_fallback: true } : {}),1217 ...(outcome.kind === "fallback" ? { dcr_fallback: true } : {}),
1192 });1218 });
1193 if (outcome.kind === "fallback") {1219 if (outcome.kind === "fallback") {
1220 setOAuthFallbackProbe("probe" in outcome ? outcome.probe : null);
1194 setDcrFailed(true);1221 setDcrFailed(true);
1195 toast.error("Automatic setup unavailable — register an app");1222 toast.error("Automatic setup unavailable — register an app");
1196 }1223 }
@@ -1266,17 +1293,30 @@ function AddAccountModalView(props: AddAccountModalProps) { · 69 unmodified lines
1266 )}1293 )}
1267 prefill={{1294 prefill={{
1268 authorizationUrl:1295 authorizationUrl:
1269 oauthHandoffPrefill?.authorizationUrl ?? method.oauth?.authorizationUrl,1296 oauthHandoffPrefill?.authorizationUrl ??
1270 tokenUrl: oauthHandoffPrefill?.tokenUrl ?? method.oauth?.tokenUrl,1297 method.oauth?.authorizationUrl ??
1298 oauthFallbackProbe?.authorizationUrl,
1299 tokenUrl:
1300 oauthHandoffPrefill?.tokenUrl ??
1301 method.oauth?.tokenUrl ??
1302 oauthFallbackProbe?.tokenUrl,
1303 resource:
1304 oauthHandoffPrefill?.resource ??
1305 oauthFallbackProbe?.resource ??
1306 method.oauth?.discoveryUrl ??
1307 null,
1271 scopes: method.oauth?.scopes,1308 scopes: method.oauth?.scopes,
1272 registrationEndpoint: method.oauth?.registrationEndpoint,1309 discoveredScopes: oauthFallbackProbe?.scopesSupported,
1310 registrationEndpoint:
1311 method.oauth?.registrationEndpoint ??
1312 oauthFallbackProbe?.registrationEndpoint ??
1313 undefined,
1314 tokenEndpointAuthMethodsSupported:
1315 oauthFallbackProbe?.tokenEndpointAuthMethodsSupported,
1273 ...(oauthHandoffPrefill?.grant ? { grant: oauthHandoffPrefill.grant } : {}),1316 ...(oauthHandoffPrefill?.grant ? { grant: oauthHandoffPrefill.grant } : {}),
1274 ...(oauthHandoffPrefill?.clientId1317 ...(oauthHandoffPrefill?.clientId
1275 ? { clientId: oauthHandoffPrefill.clientId }1318 ? { clientId: oauthHandoffPrefill.clientId }
1276 : {}),1319 : {}),
1277 ...(oauthHandoffPrefill?.resource != null
1278 ? { resource: oauthHandoffPrefill.resource }
1279 : {}),
1280 }}1320 }}
1281 fixedSlug={1321 fixedSlug={
1282 oauthClientHandoff?.slug != null && oauthClientHandoff.slug.length > 01322 oauthClientHandoff?.slug != null && oauthClientHandoff.slug.length > 0
packages/react/src/components/auth-template-editor.tsx+2−2
@@ -89,8 +89,8 @@ export interface AuthTemplateEditorProps { · 88 unmodified lines
89 * tabs. Clicking a pill applies its value as an editable default. */89 * tabs. Clicking a pill applies its value as an editable default. */
90 readonly presets?: readonly AuthTemplateEditorPreset[];90 readonly presets?: readonly AuthTemplateEditorPreset[];
91 /** Some integrations, notably MCP, discover OAuth metadata from the server at91 /** Some integrations, notably MCP, discover OAuth metadata from the server at
92 * connection time. In that case the add form should declare OAuth but not92 * connection time. In that case the add form should declare OAuth without
93 * expose editable provider URLs/scopes that are not persisted. */93 * editable provider URLs or scopes. */
94 readonly oauthMetadata?: "editable" | "discovered";94 readonly oauthMetadata?: "editable" | "discovered";
95}95}
96 96
packages/react/src/components/oauth-client-form.test.ts+28−0
@@ -0,0 +1,28 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { registrationScopes } from "./oauth-client-form";
4
5describe("registrationScopes", () => {
6 it("sends declared scopes and ignores discovered when declared scopes exist", () => {
7 // Declared (template) scopes are authoritative — a discovered set never wins.
8 expect(registrationScopes(["a", "b"], ["x"])).toEqual(["a", "b"]);
9 });
10
11 it("sends discovered scopes when none are declared", () => {
12 expect(registrationScopes([], ["x", "y"])).toEqual(["x", "y"]);
13 });
14
15 it("uses the current discovered set so a re-Discover replaces a prefilled probe", () => {
16 // Probe of server A seeds the discovered state; a later Discover of server B
17 // replaces it. With nothing declared, B's scopes register — not the stale A
18 // set that arrived via the DCR fallback prefill.
19 const seededFromProbeA = ["a:read"];
20 const rediscoveredFromB = ["b:write"];
21 expect(registrationScopes([], seededFromProbeA)).toEqual(["a:read"]);
22 expect(registrationScopes([], rediscoveredFromB)).toEqual(["b:write"]);
23 });
24
25 it("returns empty when nothing is declared or discovered", () => {
26 expect(registrationScopes([], [])).toEqual([]);
27 });
28});
packages/react/src/components/oauth-client-form.tsx+39−4
@@ -39,7 +39,14 @@ export interface OAuthClientFormPrefill { · 38 unmodified lines
39 readonly authorizationUrl?: string;39 readonly authorizationUrl?: string;
40 readonly tokenUrl?: string;40 readonly tokenUrl?: string;
41 readonly resource?: string | null;41 readonly resource?: string | null;
42 /** Template-DECLARED scopes (e.g. an OpenAPI bundle's scope union). Immutable
43 * in the form and authoritative — always sent at registration, surviving
44 * Discover. */
42 readonly scopes?: readonly string[];45 readonly scopes?: readonly string[];
46 /** Scopes already DISCOVERED from a prior server probe (e.g. a DCR fallback).
47 * Seed the form's discovered state; a later in-form Discover replaces them,
48 * and they are only sent when no declared scopes exist. */
49 readonly discoveredScopes?: readonly string[];
43 readonly grant?: OAuthGrant;50 readonly grant?: OAuthGrant;
44 /** Client id to seed (e.g. when editing an existing app). NOT a secret — the51 /** Client id to seed (e.g. when editing an existing app). NOT a secret — the
45 * secret is never returned, so it is always re-entered. */52 * secret is never returned, so it is always re-entered. */
@@ -48,8 +55,21 @@ export interface OAuthClientFormPrefill { · 2 unmodified lines
48 * method or surfaced by Discover), the form offers a one-click "Register55 * method or surfaced by Discover), the form offers a one-click "Register
49 * automatically" path that needs no pasted client id/secret. */56 * automatically" path that needs no pasted client id/secret. */
50 readonly registrationEndpoint?: string;57 readonly registrationEndpoint?: string;
58 /** Token-endpoint auth methods the server advertises (RFC 8414), so a
59 * prefilled "Register automatically" picks the right client-auth method
60 * instead of defaulting to public ("none"). */
61 readonly tokenEndpointAuthMethodsSupported?: readonly string[];
51}62}
52 63
64/** The scopes to register via DCR. The integration's DECLARED (template) scopes
65 * are authoritative and immutable, so they win. Otherwise the DISCOVERED set is
66 * used — seeded from a prior server probe and replaced by any in-form Discover —
67 * so re-discovering a different issuer can't register a stale set. */
68export const registrationScopes = (
69 declaredScopes: readonly string[],
70 discoveredScopes: readonly string[],
71): readonly string[] => (declaredScopes.length > 0 ? declaredScopes : discoveredScopes);
72
53export function OAuthClientForm(props: {73export function OAuthClientForm(props: {
54 /** Human label for the integration this app backs (used in toasts + default name). */74 /** Human label for the integration this app backs (used in toasts + default name). */
55 readonly integrationName: string;75 readonly integrationName: string;
@@ -108,6 +128,15 @@ export function OAuthClientForm(props: { · 52 unmodified lines
108 const [authorizationUrl, setAuthorizationUrl] = useState(prefill?.authorizationUrl ?? "");128 const [authorizationUrl, setAuthorizationUrl] = useState(prefill?.authorizationUrl ?? "");
109 const [tokenUrl, setTokenUrl] = useState(prefill?.tokenUrl ?? "");129 const [tokenUrl, setTokenUrl] = useState(prefill?.tokenUrl ?? "");
110 const [resource, setResource] = useState(prefill?.resource ?? null);130 const [resource, setResource] = useState(prefill?.resource ?? null);
131 // Scopes to register with DCR. The form has no scopes input (DCR sends them
132 // verbatim). Declared scopes are the integration's TEMPLATE scopes — immutable
133 // and authoritative, so they survive Discover. Discovered scopes are seeded
134 // from a prior probe (e.g. a DCR fallback) and replaced by any in-form
135 // Discover; they are only sent when nothing is declared.
136 const declaredScopes = prefill?.scopes ?? [];
137 const [discoveredScopes, setDiscoveredScopes] = useState<readonly string[]>(
138 prefill?.discoveredScopes ?? [],
139 );
111 const [discovering, setDiscovering] = useState(false);140 const [discovering, setDiscovering] = useState(false);
112 const [submitting, setSubmitting] = useState(false);141 const [submitting, setSubmitting] = useState(false);
113 // DCR (RFC 7591): the registration endpoint + advertised auth methods. Seeded142 // DCR (RFC 7591): the registration endpoint + advertised auth methods. Seeded
@@ -116,7 +145,9 @@ export function OAuthClientForm(props: { · 2 unmodified lines
116 const [registrationEndpoint, setRegistrationEndpoint] = useState(145 const [registrationEndpoint, setRegistrationEndpoint] = useState(
117 prefill?.registrationEndpoint ?? "",146 prefill?.registrationEndpoint ?? "",
118 );147 );
119 const [authMethods, setAuthMethods] = useState<readonly string[] | undefined>(undefined);148 const [authMethods, setAuthMethods] = useState<readonly string[] | undefined>(
149 prefill?.tokenEndpointAuthMethodsSupported,
150 );
120 const [registering, setRegistering] = useState(false);151 const [registering, setRegistering] = useState(false);
121 152
122 // Endpoints/scopes usually come prefilled from the integration's declared153 // Endpoints/scopes usually come prefilled from the integration's declared
@@ -166,6 +197,10 @@ export function OAuthClientForm(props: { · 43 unmodified lines
166 setAuthorizationUrl(result.authorizationUrl);197 setAuthorizationUrl(result.authorizationUrl);
167 setTokenUrl(result.tokenUrl);198 setTokenUrl(result.tokenUrl);
168 setResource(result.resource ?? null);199 setResource(result.resource ?? null);
200 // Record the discovered scopes. Declared scopes (if any) still take
201 // precedence at registration, so a re-Discover reflects the latest server
202 // without clobbering a declared set.
203 setDiscoveredScopes(result.scopesSupported ?? []);
169 // Capture DCR availability so the "Register automatically" path shows for a204 // Capture DCR availability so the "Register automatically" path shows for a
170 // pasted MCP/issuer URL without any client id/secret.205 // pasted MCP/issuer URL without any client id/secret.
171 setRegistrationEndpoint(result.registrationEndpoint ?? "");206 setRegistrationEndpoint(result.registrationEndpoint ?? "");
@@ -189,9 +224,9 @@ export function OAuthClientForm(props: { · 17 unmodified lines
189 authorizationUrl: authorizationUrl.trim(),224 authorizationUrl: authorizationUrl.trim(),
190 tokenUrl: tokenUrl.trim(),225 tokenUrl: tokenUrl.trim(),
191 resource,226 resource,
192 // DCR sends the integration's declared scopes to the AS at registration227 // DCR sends the integration's declared scopes, or the discovered set when
193 // (the app itself stores none).228 // none are declared, to the AS at registration (the app stores none).
194 scopes: [...(prefill?.scopes ?? [])],229 scopes: [...registrationScopes(declaredScopes, discoveredScopes)],
195 tokenEndpointAuthMethodsSupported: authMethods,230 tokenEndpointAuthMethodsSupported: authMethods,
196 clientName: name.trim(),231 clientName: name.trim(),
197 redirectUri: oauthCallbackUrl(),232 redirectUri: oauthCallbackUrl(),
packages/react/src/plugins/use-effective-oauth-client.test.ts+13−0
@@ -96,6 +96,19 @@ describe("selectClientsForEndpoints", () => { · 95 unmodified lines
96 expect(result.unmatched).toEqual([]);96 expect(result.unmatched).toEqual([]);
97 });97 });
98 98
99 it("with requireEndpointMatch and no endpoints, matches NOTHING (drives the register CTA)", () => {
100 // A server-targeting integration (MCP, endpoints discovered at connect) must
101 // not auto-select an unrelated provider's app just because nothing was
102 // declared to filter on.
103 const result = selectClientsForEndpoints([google, spotify], { requireEndpointMatch: true });
104 expect(result.endpointMatched).toBe(false);
105 expect(result.matched).toEqual([]);
106 expect(result.unmatched.map((a: OAuthClientOption) => String(a.slug))).toEqual([
107 "google-app",
108 "spotify-app",
109 ]);
110 });
111
99 it("matches local-dev MCP by exact host when tldts cannot resolve a root domain", () => {112 it("matches local-dev MCP by exact host when tldts cannot resolve a root domain", () => {
100 const local = app("local-mcp", {113 const local = app("local-mcp", {
101 authorizationUrl: "http://localhost:8787/authorize",114 authorizationUrl: "http://localhost:8787/authorize",
packages/react/src/plugins/use-effective-oauth-client.tsx+17−2
@@ -97,7 +97,15 @@ const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClient · 96 unmodified lines
97 */97 */
98export function selectClientsForEndpoints(98export function selectClientsForEndpoints(
99 all: readonly OAuthClientOption[],99 all: readonly OAuthClientOption[],
100 endpoints: { readonly tokenUrl?: string; readonly authorizationUrl?: string },100 endpoints: {
101 readonly tokenUrl?: string;
102 readonly authorizationUrl?: string;
103 /** When set, an integration that targets a SPECIFIC server (MCP, whose
104 * endpoints are discovered at connect) must match by endpoint — absent
105 * endpoints mean NO match (show the register CTA), never "every app
106 * matches". Prevents auto-selecting an unrelated provider's app. */
107 readonly requireEndpointMatch?: boolean;
108 },
101): {109): {
102 readonly matched: readonly OAuthClientOption[];110 readonly matched: readonly OAuthClientOption[];
103 readonly unmatched: readonly OAuthClientOption[];111 readonly unmatched: readonly OAuthClientOption[];
@@ -107,8 +115,13 @@ export function selectClientsForEndpoints( · 3 unmodified lines
107 const wantedAuthorizationRoot = endpoints.authorizationUrl115 const wantedAuthorizationRoot = endpoints.authorizationUrl
108 ? getRootDomain(endpoints.authorizationUrl)116 ? getRootDomain(endpoints.authorizationUrl)
109 : undefined;117 : undefined;
110 // No declared endpoints → no filter; every app is usable.118 // No declared endpoints. A server-targeting integration must NOT match every
119 // app (it would auto-select an unrelated provider); surface the register CTA
120 // instead. Otherwise (no endpoint filter at all) every app is usable.
111 if (!wantedTokenRoot && !wantedAuthorizationRoot) {121 if (!wantedTokenRoot && !wantedAuthorizationRoot) {
122 if (endpoints.requireEndpointMatch) {
123 return { matched: [], unmatched: sortUserFirst(all), endpointMatched: false };
124 }
112 return { matched: sortUserFirst(all), unmatched: [], endpointMatched: true };125 return { matched: sortUserFirst(all), unmatched: [], endpointMatched: true };
113 }126 }
114 const matched: OAuthClientOption[] = [];127 const matched: OAuthClientOption[] = [];
@@ -133,6 +146,7 @@ export function selectClientsForEndpoints( · 18 unmodified lines
133export function useOAuthClientsForIntegration(opts: {146export function useOAuthClientsForIntegration(opts: {
134 readonly tokenUrl?: string;147 readonly tokenUrl?: string;
135 readonly authorizationUrl?: string;148 readonly authorizationUrl?: string;
149 readonly requireEndpointMatch?: boolean;
136}): UseOAuthClientsResult {150}): UseOAuthClientsResult {
137 // Read the optimistic list so a just-registered/edited/removed app paints151 // Read the optimistic list so a just-registered/edited/removed app paints
138 // immediately, instead of flashing the stale server list until the refetch152 // immediately, instead of flashing the stale server list until the refetch
@@ -153,6 +167,7 @@ export function useOAuthClientsForIntegration(opts: { · 14 unmodified lines
153 const { matched, unmatched, endpointMatched } = selectClientsForEndpoints(all, {167 const { matched, unmatched, endpointMatched } = selectClientsForEndpoints(all, {
154 tokenUrl: opts.tokenUrl,168 tokenUrl: opts.tokenUrl,
155 authorizationUrl: opts.authorizationUrl,169 authorizationUrl: opts.authorizationUrl,
170 requireEndpointMatch: opts.requireEndpointMatch,
156 });171 });
157 // EXPLICIT outcome: when at least one app matched (or no endpoint was172 // EXPLICIT outcome: when at least one app matched (or no endpoint was
158 // declared) we present the matched subset. When an endpoint was declared but173 // declared) we present the matched subset. When an endpoint was declared but

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t