History

Carry the enterprise identity provider declaration to the console

Rhys Sullivan committed 2 months ago

Commit febbce91 parent 89a7f457 files · +414 −79Browse files

Unified · Split

7 files
  1. packages/plugins/mcp/src/react/EditMcpIntegration.tsx +85 −4
  2. packages/plugins/mcp/src/react/McpSignInButton.tsx +7 −17
  3. packages/plugins/mcp/src/react/auth-method-config.test.ts +109 −0
  4. packages/plugins/mcp/src/react/auth-method-config.ts +51 −7
  5. packages/react/src/components/enterprise-idp-section.test.ts +50 −19
  6. packages/react/src/components/enterprise-idp-section.tsx +91 −31
  7. packages/react/src/lib/auth-placements.tsx +21 −1
packages/plugins/mcp/src/react/EditMcpIntegration.tsx+85−4
@@ -14,18 +14,24 @@ import { · 13 unmodified lines
1414 type AuthMethodSeed,
1515 } from "@executor-js/react/components/auth-method-list-editor";
1616 import { Badge } from "@executor-js/react/components/badge";
17+import { Label } from "@executor-js/react/components/label";
18+import { Switch } from "@executor-js/react/components/switch";
19+import { useEnterpriseIdentityProviderDescriptor } from "@executor-js/react/components/enterprise-idp-section";
1720 import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
1821
1922 import { configureMcpAuth, mcpServerAtom } from "./atoms";
2023 import type {
2124 McpAuthMethod,
2225 McpCanonicalAuthMethodInput,
26+ McpEnterpriseIdentityProvider,
2327 McpIntegrationConfig,
2428 } from "../sdk/types";
2529 import {
2630 editorValueFromMcpAuthMethod,
2731 mcpAuthMethodInputFromEditorValue,
32+ mcpOAuthMethodInput,
2833 mcpWireAuthInput,
34+ sameEnterpriseIdentityProvider,
2935 } from "./auth-method-config";
3036
3137 type McpServer = {
@@ -95,16 +101,44 @@ function RemoteEdit(props: { · 63 unmodified lines
95101
96102 const [error, setError] = useState<string | null>(null);
97103
104+ // Whether this server is declared enterprise-managed, and by which
105+ // registration. Read off the stored oauth2 method rather than kept as a
106+ // parallel flag: the declaration IS the state, and the toggle below only
107+ // decides whether the organization's registration is attached to it.
108+ const declaredProvider = useMemo<McpEnterpriseIdentityProvider | undefined>(() => {
109+ for (const method of server.config.authenticationTemplate) {
110+ if (method.kind === "oauth2" && method.enterpriseIdentityProvider !== undefined) {
111+ return method.enterpriseIdentityProvider;
112+ }
113+ }
114+ return undefined;
115+ }, [server.config.authenticationTemplate]);
116+
117+ const organizationProvider = useEnterpriseIdentityProviderDescriptor() ?? undefined;
118+ const [managed, setManaged] = useState(declaredProvider !== undefined);
119+ // The declaration this save would write: the organization's registration
120+ // while the toggle is on, and — when the organization has since removed its
121+ // registration — whatever the server already names, so an unrelated edit
122+ // cannot quietly un-manage a live server.
123+ const nextProvider: McpEnterpriseIdentityProvider | undefined = managed
124+ ? (organizationProvider ?? declaredProvider)
125+ : undefined;
126+
98127 // The edited methods, slugs preserved for seeded rows so existing
99128 // connections (bound by template slug) stay attached. New rows omit the
100− // slug — the backend assigns kind-based ones.
129+ // slug — the backend assigns kind-based ones. Each row is reconciled against
130+ // the method it was seeded from, because `configureMcpAuth` runs in `replace`
131+ // mode here: anything the credential editor cannot express — the
132+ // enterprise-managed declaration above all — has to be carried forward
133+ // deliberately or it is erased by an unrelated edit.
101134 const editedMethods = useMemo<readonly McpCanonicalAuthMethodInput[]>(
102135 () =>
103136 list.rows.map((row: AuthMethodRow): McpCanonicalAuthMethodInput => {
104− const input = mcpAuthMethodInputFromEditorValue(row.value);
105− return row.seedSlug !== undefined ? { ...input, slug: row.seedSlug } : input;
137+ const edited = mcpAuthMethodInputFromEditorValue(row.value);
138+ const declared = edited.kind === "oauth2" ? mcpOAuthMethodInput(nextProvider) : edited;
139+ return row.seedSlug !== undefined ? { ...declared, slug: row.seedSlug } : declared;
106140 }),
107− [list.rows],
141+ [list.rows, nextProvider],
108142 );
109143
110144 const methodsChanged = useMemo(() => {
@@ -118,10 +152,25 @@ function RemoteEdit(props: { · 7 unmodified lines
118152 if (method.kind === "apikey" && current.kind === "apikey") {
119153 return !samePlacements(method.placements, current.placements);
120154 }
155+ if (method.kind === "oauth2" && current.kind === "oauth2") {
156+ return !sameEnterpriseIdentityProvider(
157+ method.enterpriseIdentityProvider,
158+ current.enterpriseIdentityProvider,
159+ );
160+ }
121161 return false;
122162 });
123163 }, [editedMethods, server.config.authenticationTemplate]);
124164
165+ const hasOAuthMethod = server.config.authenticationTemplate.some(
166+ (method: McpAuthMethod) => method.kind === "oauth2",
167+ );
168+ // Offered only where it can mean something: this server authenticates with
169+ // OAuth, and either the organization has registered an identity provider or
170+ // this server is already managed by one (so it can still be turned off).
171+ const canDeclareManaged =
172+ hasOAuthMethod && (organizationProvider !== undefined || declaredProvider !== undefined);
173+
125174 // Staged apply, run by the sheet's Save when the method list changed.
126175 const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => {
127176 setError(null);
@@ -168,6 +217,38 @@ function RemoteEdit(props: { · 40 unmodified lines
168217 footerHint="Connections pick one of these methods. Removing a method detaches connections created against it."
169218 />
170219
220+ {/* Enterprise-Managed Authorization, per server. The organization's
221+ identity provider is registered once (Organization settings); this is
222+ where an administrator says WHICH servers authorize through it.
223+ Declaring it can never take an ordinary server off the interactive
224+ flow — the connect path still requires the server to advertise the
225+ grant profile — so the copy promises a route, not an outcome. */}
226+ {canDeclareManaged ? (
227+ <div className="flex items-start gap-3 rounded-md border border-border/60 px-3 py-3">
228+ <Switch
229+ id="ema-managed-server"
230+ checked={managed}
231+ onCheckedChange={setManaged}
232+ className="mt-0.5"
233+ />
234+ <div className="min-w-0 space-y-1">
235+ <Label htmlFor="ema-managed-server" className="text-sm font-medium text-foreground">
236+ Managed by your organization
237+ </Label>
238+ <p className="text-xs text-muted-foreground">
239+ Members connect with their work identity instead of consenting to this server. Your
240+ identity provider decides who gets access, and revokes it.
241+ </p>
242+ {organizationProvider === undefined ? (
243+ <p className="text-xs text-muted-foreground">
244+ Your organization no longer has an identity provider registered. This server keeps
245+ the one it already names until you turn this off.
246+ </p>
247+ ) : null}
248+ </div>
249+ </div>
250+ ) : null}
251+
171252 {error && <FormErrorAlert message={error} />}
172253 </div>
173254 );
packages/plugins/mcp/src/react/McpSignInButton.tsx+7−17
@@ -2,18 +2,14 @@ import { useMemo, useState } from "react"; · 1 unmodified line
22 import { useAtomValue } from "@effect/atom-react";
33 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
44
5−import {
6− AuthTemplateSlug,
7− IntegrationSlug,
8− type Connection,
9− type Owner,
10−} from "@executor-js/sdk/shared";
5+import { IntegrationSlug, type Connection, type Owner } from "@executor-js/sdk/shared";
116 import { connectionsAllAtom } from "@executor-js/react/api/atoms";
127 import { AddAccountModal } from "@executor-js/react/components/add-account-modal";
138 import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in";
149 import type { AuthMethod } from "@executor-js/react/lib/auth-placements";
1510
1611 import { mcpServerAtom } from "./atoms";
12+import { authMethodsFromConfig } from "./auth-method-config";
1713 import type { McpAuthMethod } from "../sdk/types";
1814
1915 // ---------------------------------------------------------------------------
@@ -47,21 +43,15 @@ export default function McpSignInButton(props: { integrationId: string; owner?: · 27 unmodified lines
4743 (connection: Connection) => connection.integration === slug,
4844 );
4945
46+ // Projected through the plugin's ONE codec rather than assembled here: the
47+ // server's enterprise-managed declaration rides on this method, and a
48+ // hand-built copy silently dropped it — leaving this button offering
49+ // per-server consent for a server the organization manages.
5050 const methods = useMemo<readonly AuthMethod[]>(
5151 () =>
5252 remote === null || oauthMethod === null
5353 ? []
54− : [
55− {
56− id: oauthMethod.slug,
57− label: "OAuth",
58− kind: "oauth",
59− source: "spec",
60− template: AuthTemplateSlug.make(oauthMethod.slug),
61− placements: [],
62− oauth: { discoveryUrl: remote.endpoint, supportsDynamicRegistration: true },
63− },
64− ],
54+ : authMethodsFromConfig([oauthMethod], remote.endpoint),
6555 [remote, oauthMethod],
6656 );
6757 const initialState = useMemo(
packages/plugins/mcp/src/react/auth-method-config.test.ts+109−0
@@ -1,4 +1,5 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import { OAuthClientSlug } from "@executor-js/sdk/shared";
23 import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
34
45 import {
@@ -6,6 +7,8 @@ import { · 1 unmodified line
67 editorValueFromMcpAuthMethod,
78 mcpAuthMethodInputFromEditorValue,
89 mcpAuthMethodInputsFromPlacements,
10+ mcpOAuthMethodInput,
11+ sameEnterpriseIdentityProvider,
912 } from "./auth-method-config";
1013
1114 describe("mcpAuthMethodInputFromEditorValue", () => {
@@ -175,6 +178,112 @@ describe("authMethodsFromConfig", () => { · 163 unmodified lines
175178 });
176179 });
177180
181+// ---------------------------------------------------------------------------
182+// Enterprise-Managed Authorization: the per-server declaration.
183+//
184+// The declaration is a POINTER at the organization's identity-provider
185+// registration, and it is what puts a server on the work-identity route
186+// instead of per-server consent. Two things must hold or the feature silently
187+// stops working: it has to reach the console (a dropped pointer means the
188+// connect path offers ordinary consent for a managed server), and it must not
189+// be invented by any surface that cannot see the organization's registration.
190+// ---------------------------------------------------------------------------
191+
192+const PROVIDER = {
193+ client: OAuthClientSlug.make("enterprise-identity-provider"),
194+ clientOwner: "org",
195+} as const;
196+
197+describe("authMethodsFromConfig · enterprise-managed declaration", () => {
198+ it("carries the server's identity-provider pointer onto the rendered method", () => {
199+ const methods = authMethodsFromConfig(
200+ [{ slug: "oauth2", kind: "oauth2", enterpriseIdentityProvider: PROVIDER }],
201+ "https://mcp.example.com/mcp",
202+ );
203+ expect(methods[0]?.oauth?.enterpriseIdentityProvider).toEqual(PROVIDER);
204+ });
205+
206+ it("leaves the interactive route advertised beside it", () => {
207+ // Declaring a provider asks the connect path to TRY the enterprise branch.
208+ // Whether it is taken still depends on the server advertising the grant
209+ // profile, so the ordinary route must remain available.
210+ const methods = authMethodsFromConfig(
211+ [{ slug: "oauth2", kind: "oauth2", enterpriseIdentityProvider: PROVIDER }],
212+ "https://mcp.example.com/mcp",
213+ );
214+ expect(methods[0]?.oauth?.supportsDynamicRegistration).toBe(true);
215+ expect(methods[0]?.oauth?.discoveryUrl).toBe("https://mcp.example.com/mcp");
216+ });
217+
218+ it("declares nothing for an ordinary oauth2 server", () => {
219+ const methods = authMethodsFromConfig(
220+ [{ slug: "oauth2", kind: "oauth2" }],
221+ "https://mcp.example.com/mcp",
222+ );
223+ expect(methods[0]?.oauth?.enterpriseIdentityProvider).toBeUndefined();
224+ });
225+});
226+
227+describe("mcpOAuthMethodInput", () => {
228+ it("attaches the organization's registration when the server is managed", () => {
229+ expect(mcpOAuthMethodInput(PROVIDER)).toEqual({
230+ kind: "oauth2",
231+ enterpriseIdentityProvider: PROVIDER,
232+ });
233+ });
234+
235+ it("omits the key entirely when it is not — never an explicit undefined", () => {
236+ // `configureMcpAuth` decodes this against a union; an explicit
237+ // `enterpriseIdentityProvider: undefined` is a different wire value and is
238+ // rejected, so absence has to be real absence.
239+ const input = mcpOAuthMethodInput(undefined);
240+ expect(input).toEqual({ kind: "oauth2" });
241+ expect(Object.hasOwn(input, "enterpriseIdentityProvider")).toBe(false);
242+ });
243+});
244+
245+describe("mcpAuthMethodInputFromEditorValue · enterprise-managed declaration", () => {
246+ it("invents no declaration from the credential editor", () => {
247+ // The editor edits credentials. Server policy is not one, and a surface
248+ // that cannot see the organization's registration must not guess at it —
249+ // the save path re-attaches it deliberately instead.
250+ expect(
251+ mcpAuthMethodInputFromEditorValue({
252+ kind: "oauth",
253+ authorizationUrl: "",
254+ tokenUrl: "",
255+ scopes: [],
256+ }),
257+ ).toEqual({ kind: "oauth2" });
258+ });
259+});
260+
261+describe("sameEnterpriseIdentityProvider", () => {
262+ it("compares the pointer by value, not by reference", () => {
263+ // The stored copy is decoded from JSON, so it is never the same object as
264+ // the one the console just built; a reference check would report every
265+ // save as a change.
266+ expect(sameEnterpriseIdentityProvider({ ...PROVIDER }, { ...PROVIDER })).toBe(true);
267+ });
268+
269+ it("distinguishes a different registration", () => {
270+ expect(sameEnterpriseIdentityProvider(PROVIDER, { ...PROVIDER, clientOwner: "user" })).toBe(
271+ false,
272+ );
273+ expect(
274+ sameEnterpriseIdentityProvider(PROVIDER, {
275+ ...PROVIDER,
276+ client: OAuthClientSlug.make("other"),
277+ }),
278+ ).toBe(false);
279+ });
280+
281+ it("treats declaring and not declaring as different", () => {
282+ expect(sameEnterpriseIdentityProvider(PROVIDER, undefined)).toBe(false);
283+ expect(sameEnterpriseIdentityProvider(undefined, undefined)).toBe(true);
284+ });
285+});
286+
178287 describe("mcpAuthMethodInputsFromPlacements", () => {
179288 it("builds ONE method carrying every named placement", () => {
180289 expect(
packages/plugins/mcp/src/react/auth-method-config.ts+51−7
@@ -21,6 +21,8 @@ import type { · 20 unmodified lines
2121 McpAuthMethod,
2222 McpAuthMethodInput,
2323 McpCanonicalAuthMethodInput,
24+ McpEnterpriseIdentityProvider,
25+ McpOAuthMethod,
2426 McpStdioEnvMethod,
2527 } from "../sdk/types";
2628
@@ -48,20 +50,37 @@ export const mcpWireAuthInput = ( · 21 unmodified lines
4850 method: McpAuthMethod | McpCanonicalAuthMethodInput,
4951 ): McpAuthMethodInput => wireAuthInputFromShared(method) as McpAuthMethodInput;
5052
51−const oauthAuthMethod = (slug: string, endpoint: string): AuthMethod => ({
52− id: slug,
53+const oauthAuthMethod = (method: McpOAuthMethod, endpoint: string): AuthMethod => ({
54+ id: method.slug,
5355 label: "OAuth",
5456 kind: "oauth",
55− source: slug.startsWith("custom_") ? "custom" : "spec",
56− template: AuthTemplateSlug.make(slug),
57+ source: method.slug.startsWith("custom_") ? "custom" : "spec",
58+ template: AuthTemplateSlug.make(method.slug),
5759 placements: [],
58− oauth: { discoveryUrl: endpoint, supportsDynamicRegistration: true },
60+ oauth: {
61+ discoveryUrl: endpoint,
62+ supportsDynamicRegistration: true,
63+ // The server's own enterprise-managed declaration, carried onto the
64+ // presentational method. `supportsDynamicRegistration` stays true beside
65+ // it on purpose: declaring an identity provider is an ADDITIONAL route, not
66+ // a replacement, and a server that turns out not to advertise the ID-JAG
67+ // profile must still be connectable the ordinary way.
68+ ...(method.enterpriseIdentityProvider === undefined
69+ ? {}
70+ : { enterpriseIdentityProvider: method.enterpriseIdentityProvider }),
71+ },
5972 });
6073
6174 /** Convert a generic editor value into one MCP auth-method input (no slug —
6275 * the backend assigns carrier-derived slugs). An apikey value keeps every
6376 * named placement (headers and query params mix freely); one with no usable
64− * placement falls back to `none`. */
77+ * placement falls back to `none`.
78+ *
79+ * Deliberately carries NO enterprise-managed declaration: that is server
80+ * policy, not a credential, so it has no editor field to come from. The
81+ * surface that saves a managed server (`EditMcpIntegration`) re-attaches it
82+ * explicitly from its own toggle, which is what keeps `configureMcpAuth`'s
83+ * `replace` mode from erasing it on an unrelated edit. */
6584 export function mcpAuthMethodInputFromEditorValue(
6685 value: AuthTemplateEditorValue,
6786 ): McpCanonicalAuthMethodInput {
@@ -71,6 +90,31 @@ export function mcpAuthMethodInputFromEditorValue( · 3 unmodified lines
7190 }) as McpCanonicalAuthMethodInput;
7291 }
7392
93+/** One oauth2 method input carrying the organization's declaration, or none.
94+ *
95+ * The single place an enterprise-managed pointer is attached to a method on
96+ * the way out. Written as a constructor rather than a spread at each call site
97+ * so the absent case stays genuinely ABSENT: an explicit
98+ * `enterpriseIdentityProvider: undefined` is a different value on the wire,
99+ * and the union that decodes it rejects it. */
100+export const mcpOAuthMethodInput = (
101+ provider: McpEnterpriseIdentityProvider | undefined,
102+): McpCanonicalAuthMethodInput =>
103+ provider === undefined
104+ ? { kind: "oauth2" }
105+ : { kind: "oauth2", enterpriseIdentityProvider: provider };
106+
107+/** Whether two enterprise-managed declarations name the same registration.
108+ * Compared field-by-field because the pointer travels through JSON and a
109+ * decoded copy is never reference-equal to the stored one. */
110+export const sameEnterpriseIdentityProvider = (
111+ a: McpEnterpriseIdentityProvider | undefined,
112+ b: McpEnterpriseIdentityProvider | undefined,
113+): boolean =>
114+ a === undefined || b === undefined
115+ ? a === b
116+ : String(a.client) === String(b.client) && a.clientOwner === b.clientOwner;
117+
74118 /** Convert one stored MCP method into the generic editor value. */
75119 export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplateEditorValue {
76120 if (method.kind === "oauth2") {
@@ -89,7 +133,7 @@ export function authMethodsFromConfig( · 12 unmodified lines
89133 endpoint: string,
90134 ): AuthMethod[] {
91135 return methods.map((method: McpAuthMethod): AuthMethod => {
92− if (method.kind === "oauth2") return oauthAuthMethod(method.slug, endpoint);
136+ if (method.kind === "oauth2") return oauthAuthMethod(method, endpoint);
93137 if (method.kind === "stdio_env") return stdioEnvAuthMethod(method);
94138 return authMethodFromSharedTemplate(method);
95139 });
packages/react/src/components/enterprise-idp-section.test.ts+50−19
@@ -4,6 +4,7 @@ import { OAuthClientSlug, type OAuthClientSummary } from "@executor-js/sdk/share · 3 unmodified lines
44 import {
55 ENTERPRISE_IDENTITY_PROVIDER_CLIENT_OWNER,
66 ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG,
7+ ENTERPRISE_IDENTITY_PROVIDER_DESCRIPTOR,
78 canSubmitEnterpriseIdentityProvider,
89 enterpriseIdentityProviderPayload,
910 findEnterpriseIdentityProvider,
@@ -42,14 +43,20 @@ describe("findEnterpriseIdentityProvider", () => { · 32 unmodified lines
4243 });
4344
4445 describe("canSubmitEnterpriseIdentityProvider", () => {
45− const valid = { submitting: false, tokenUrl: "https://idp.example/token", clientId: "abc" };
46+ const valid = {
47+ submitting: false,
48+ issuerUrl: "https://idp.example/oauth2/default",
49+ clientId: "abc",
50+ };
4651
4752 it("accepts a public client with no secret", () => {
4853 expect(canSubmitEnterpriseIdentityProvider(valid)).toBe(true);
4954 });
5055
51− it("requires the token endpoint the assertion exchange is POSTed to", () => {
52− expect(canSubmitEnterpriseIdentityProvider({ ...valid, tokenUrl: " " })).toBe(false);
56+ it("requires the issuer the endpoints are discovered from", () => {
57+ // Endpoints are never typed: they are probed off the issuer, so the issuer
58+ // is the field the registration cannot proceed without.
59+ expect(canSubmitEnterpriseIdentityProvider({ ...valid, issuerUrl: " " })).toBe(false);
5360 });
5461
5562 it("requires the client id the exchange authenticates as", () => {
@@ -62,9 +69,14 @@ describe("canSubmitEnterpriseIdentityProvider", () => { · 6 unmodified lines
6269 });
6370
6471 describe("enterpriseIdentityProviderPayload", () => {
72+ const discovered = {
73+ authorizationUrl: " https://idp.example/authorize ",
74+ tokenUrl: " https://idp.example/token ",
75+ };
76+
6577 it("registers the workspace-owned app under the reserved slug", () => {
6678 const payload = enterpriseIdentityProviderPayload({
67− tokenUrl: " https://idp.example/token ",
79+ ...discovered,
6880 clientId: " abc ",
6981 clientSecret: " shh ",
7082 });
@@ -75,26 +87,45 @@ describe("enterpriseIdentityProviderPayload", () => { · 4 unmodified lines
7587 expect(payload.clientSecret).toBe("shh");
7688 });
7789
78− it("records no authorization URL, because this app never runs a redirect", () => {
79− // The enterprise-managed chain exchanges an assertion the member already
80− // holds at the token endpoint; there is no authorization endpoint to store,
81− // and inventing one would claim a flow this registration does not run.
90+ it("records the authorization endpoint the work-identity link needs", () => {
91+ // The ID-JAG exchange itself only touches the token endpoint. The
92+ // authorization endpoint is here for the hop that produces the subject
93+ // token in the first place: an assertion issued BY this provider and
94+ // audienced to THIS client, which a brokered login does not yield.
95+ expect(
96+ enterpriseIdentityProviderPayload({ ...discovered, clientId: "abc", clientSecret: "" })
97+ .authorizationUrl,
98+ ).toBe("https://idp.example/authorize");
99+ });
100+
101+ it("registers the authorization-code grant, not the enterprise grant", () => {
102+ // `id_jag` names the registration at an MCP server's authorization server.
103+ // THIS app is the registration at the identity provider — it authenticates
104+ // the exchange and runs the work-identity hop, both authorization-code
105+ // shaped.
82106 expect(
83− enterpriseIdentityProviderPayload({
84− tokenUrl: "https://idp.example/token",
85− clientId: "abc",
86− clientSecret: "",
87− }).authorizationUrl,
88− ).toBe("");
107+ enterpriseIdentityProviderPayload({ ...discovered, clientId: "abc", clientSecret: "" }).grant,
108+ ).toBe("authorization_code");
89109 });
90110
91111 it("stamps no originating integration — it backs every managed server", () => {
92112 expect(
93− enterpriseIdentityProviderPayload({
94− tokenUrl: "https://idp.example/token",
95− clientId: "abc",
96− clientSecret: "",
97− }).originIntegration,
113+ enterpriseIdentityProviderPayload({ ...discovered, clientId: "abc", clientSecret: "" })
114+ .originIntegration,
98115 ).toBeNull();
99116 });
100117 });
118+
119+describe("ENTERPRISE_IDENTITY_PROVIDER_DESCRIPTOR", () => {
120+ it("names the same registration the section writes", () => {
121+ // A server declaration and a connect request both point at the provider by
122+ // (owner, slug). If this descriptor and the payload ever disagreed, every
123+ // managed server would name a registration that does not exist.
124+ expect(String(ENTERPRISE_IDENTITY_PROVIDER_DESCRIPTOR.client)).toBe(
125+ String(ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG),
126+ );
127+ expect(ENTERPRISE_IDENTITY_PROVIDER_DESCRIPTOR.clientOwner).toBe(
128+ ENTERPRISE_IDENTITY_PROVIDER_CLIENT_OWNER,
129+ );
130+ });
131+});
packages/react/src/components/enterprise-idp-section.tsx+91−31
@@ -2,12 +2,18 @@ import { useState } from "react"; · 1 unmodified line
22 import { useAtomValue, useAtomSet } from "@effect/atom-react";
33 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
44 import * as Exit from "effect/Exit";
5−import { OAuthClientSlug, type OAuthClientSummary, type Owner } from "@executor-js/sdk/shared";
5+import {
6+ OAuthClientSlug,
7+ type EnterpriseIdentityProviderDescriptor,
8+ type OAuthClientSummary,
9+ type Owner,
10+} from "@executor-js/sdk/shared";
611 import { toast } from "sonner";
712
813 import {
914 createOAuthClientOptimistic,
1015 oauthClientsOptimisticAtom,
16+ probeOAuth,
1117 removeOAuthClientOptimistic,
1218 } from "../api/atoms";
1319 import { trackEvent } from "../api/analytics";
@@ -75,31 +81,43 @@ export const findEnterpriseIdentityProvider = ( · 61 unmodified lines
7581 String(client.slug) === String(ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG),
7682 ) ?? null;
7783
78−/** Registering the provider needs the token endpoint the RFC 8693 exchange is
79− * POSTed to and the client id it authenticates as. The secret is optional: an
80− * IdP may treat the client as public. */
84+/** The descriptor a managed server points at — the shape an MCP server's
85+ * `enterpriseIdentityProvider` declaration and `oauth.start`'s `enterprise`
86+ * input both speak. Derived from the reserved identity, never typed by hand. */
87+export const ENTERPRISE_IDENTITY_PROVIDER_DESCRIPTOR: EnterpriseIdentityProviderDescriptor = {
88+ client: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG,
89+ clientOwner: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_OWNER,
90+};
91+
92+/** Registering the provider needs the issuer to discover endpoints from and the
93+ * client id the exchange authenticates as. The secret is optional: an identity
94+ * provider may treat the registration as a public client. */
8195 export const canSubmitEnterpriseIdentityProvider = (input: {
8296 readonly submitting: boolean;
83− readonly tokenUrl: string;
97+ readonly issuerUrl: string;
8498 readonly clientId: string;
8599 }): boolean =>
86− !input.submitting && input.tokenUrl.trim().length > 0 && input.clientId.trim().length > 0;
100+ !input.submitting && input.issuerUrl.trim().length > 0 && input.clientId.trim().length > 0;
87101
88102 /** The `oauth.createClient` payload for the organization's identity provider.
89103 *
90− * `authorizationUrl` is empty by construction and that is not an omission: the
91− * enterprise-managed chain never runs a browser redirect through this
92− * registration. It exchanges an assertion the user already holds at the token
93− * endpoint (draft §4.3), so there is no authorization endpoint to record — and
94− * inventing one would claim a flow this app does not run. */
104+ * BOTH endpoints are recorded, and the authorization endpoint is the one worth
105+ * explaining. The RFC 8693 exchange that mints an ID-JAG only ever touches the
106+ * token endpoint — but the exchange needs a subject token ISSUED BY THIS
107+ * PROVIDER and audienced to THIS client, and a WorkOS-brokered login does not
108+ * produce one: there, WorkOS is the client at the customer's identity
109+ * provider, and what executor holds is a WorkOS token. Closing that gap needs
110+ * one authorization-code hop against this registration — the member's "link
111+ * your work identity" step — and that hop needs an authorize endpoint. */
95112 export const enterpriseIdentityProviderPayload = (input: {
113+ readonly authorizationUrl: string;
96114 readonly tokenUrl: string;
97115 readonly clientId: string;
98116 readonly clientSecret: string;
99117 }) => ({
100118 owner: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_OWNER,
101119 slug: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG,
102− authorizationUrl: "",
120+ authorizationUrl: input.authorizationUrl.trim(),
103121 tokenUrl: input.tokenUrl.trim(),
104122 grant: "authorization_code" as const,
105123 clientId: input.clientId.trim(),
@@ -109,6 +127,22 @@ export const enterpriseIdentityProviderPayload = (input: { · 3 unmodified lines
109127 originIntegration: null,
110128 });
111129
130+/**
131+ * The organization's identity-provider pointer, or null when none is
132+ * registered.
133+ *
134+ * The pointer, deliberately, and not the registration: a server declaration and
135+ * a connect request both need to NAME the provider, and neither has any
136+ * business holding its endpoints or its client id.
137+ */
138+export function useEnterpriseIdentityProviderDescriptor(): EnterpriseIdentityProviderDescriptor | null {
139+ const clientsResult = useAtomValue(oauthClientsOptimisticAtom);
140+ return AsyncResult.isSuccess(clientsResult) &&
141+ findEnterpriseIdentityProvider(clientsResult.value) !== null
142+ ? ENTERPRISE_IDENTITY_PROVIDER_DESCRIPTOR
143+ : null;
144+}
145+
112146 // ---------------------------------------------------------------------------
113147 // The registration dialog. Self-contained: its form state lives here and dies
114148 // with it, so re-opening after a cancel never resurfaces a half-typed secret.
@@ -122,19 +156,36 @@ function EnterpriseIdentityProviderDialog(props: { · 7 unmodified lines
122156 readonly current: OAuthClientSummary | null;
123157 }) {
124158 const { current } = props;
125− const [tokenUrl, setTokenUrl] = useState(current === null ? "" : current.tokenUrl);
159+ const [issuerUrl, setIssuerUrl] = useState(current === null ? "" : current.tokenUrl);
126160 const [clientId, setClientId] = useState(current === null ? "" : current.clientId);
127161 const [clientSecret, setClientSecret] = useState("");
128162 const [submitting, setSubmitting] = useState(false);
129163 const doCreate = useAtomSet(createOAuthClientOptimistic, { mode: "promiseExit" });
164+ const doProbe = useAtomSet(probeOAuth, { mode: "promiseExit" });
130165
131− const canSubmit = canSubmitEnterpriseIdentityProvider({ submitting, tokenUrl, clientId });
166+ const canSubmit = canSubmitEnterpriseIdentityProvider({ submitting, issuerUrl, clientId });
132167
133168 const handleSubmit = async () => {
134169 if (!canSubmit) return;
135170 setSubmitting(true);
171+ // Endpoints are DISCOVERED from the issuer, never typed. An administrator
172+ // knows their tenant's issuer; asking them to also transcribe an authorize
173+ // and a token path invites the one typo whose only symptom is every
174+ // member's connect failing later, at the identity provider, with a message
175+ // about the wrong host.
176+ const probed = await doProbe({ payload: { url: issuerUrl.trim() }, reactivityKeys: [] });
177+ if (Exit.isFailure(probed)) {
178+ setSubmitting(false);
179+ toast.error("Couldn't read that issuer's OpenID configuration. Check the URL, then retry.");
180+ return;
181+ }
136182 const exit = await doCreate({
137− payload: enterpriseIdentityProviderPayload({ tokenUrl, clientId, clientSecret }),
183+ payload: enterpriseIdentityProviderPayload({
184+ authorizationUrl: probed.value.authorizationUrl,
185+ tokenUrl: probed.value.tokenUrl,
186+ clientId,
187+ clientSecret,
188+ }),
138189 reactivityKeys: oauthClientWriteKeys,
139190 });
140191 trackEvent("oauth_client_registered", {
@@ -145,7 +196,7 @@ function EnterpriseIdentityProviderDialog(props: { · 4 unmodified lines
145196 });
146197 if (Exit.isFailure(exit)) {
147198 setSubmitting(false);
148− toast.error("Couldn't save the identity provider. Check the token URL, then retry.");
199+ toast.error("Couldn't save the identity provider. Check the issuer URL, then retry.");
149200 return;
150201 }
151202 toast.success(current === null ? "Identity provider registered" : "Identity provider updated");
@@ -166,23 +217,27 @@ function EnterpriseIdentityProviderDialog(props: { · 14 unmodified lines
166217 {current === null ? "Register Identity Provider" : "Edit Identity Provider"}
167218 </DialogTitle>
168219 <DialogDescription className="text-sm leading-relaxed">
169− Executor exchanges each member&apos;s single sign-on assertion at this endpoint for a
170− grant naming one MCP server. Your identity provider decides which members may reach
171− which servers.
220+ Register Executor as an application in your own identity provider, then paste its
221+ details here. Executor exchanges each member&apos;s work identity there for a grant
222+ naming one MCP server, so your identity provider — not the member — decides who reaches
223+ which server.
172224 </DialogDescription>
173225 </DialogHeader>
174226
175227 <div className="grid gap-4 py-1">
176228 <div className="grid gap-1.5">
177− <Label htmlFor="ema-idp-token-url" className="text-xs text-muted-foreground">
178− Token URL
229+ <Label htmlFor="ema-idp-issuer-url" className="text-xs text-muted-foreground">
230+ Issuer URL
231+ <span className="font-normal text-muted-foreground/70">
232+ endpoints are read from it
233+ </span>
179234 </Label>
180235 <Input
181− id="ema-idp-token-url"
236+ id="ema-idp-issuer-url"
182237 autoComplete="off"
183− placeholder="https://example.okta.com/oauth2/default/v1/token"
184− value={tokenUrl}
185− onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTokenUrl(e.target.value)}
238+ placeholder="https://example.okta.com/oauth2/default"
239+ value={issuerUrl}
240+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setIssuerUrl(e.target.value)}
186241 className="font-mono text-sm"
187242 />
188243 </div>
@@ -226,7 +281,11 @@ function EnterpriseIdentityProviderDialog(props: { · 37 unmodified lines
226281 </Button>
227282 </DialogClose>
228283 <Button size="sm" onClick={() => void handleSubmit()} disabled={!canSubmit}>
229− {submitting ? "Saving…" : current === null ? "Register Provider" : "Save Provider"}
284+ {submitting
285+ ? "Reading issuer…"
286+ : current === null
287+ ? "Register Provider"
288+ : "Save Provider"}
230289 </Button>
231290 </DialogFooter>
232291 </DialogContent>
@@ -261,8 +320,9 @@ export function EnterpriseIdentityProviderSection() { · 28 unmodified lines
261320 <div>
262321 <h2 className="text-sm font-medium text-foreground">Enterprise Identity Provider</h2>
263322 <p className="mt-0.5 text-sm text-muted-foreground">
264− Register the app Executor uses at your identity provider. MCP servers pointed at it
265− connect through your organization instead of asking each member for consent.
323+ Register Executor&apos;s application from your own identity provider. MCP servers marked
324+ as managed then connect with each member&apos;s work identity, instead of asking them to
325+ consent server by server.
266326 </p>
267327 </div>
268328 {provider === null ? (
@@ -282,7 +342,7 @@ export function EnterpriseIdentityProviderSection() { · 13 unmodified lines
282342 onSuccess: () =>
283343 provider === null ? (
284344 <p className="py-6 text-center text-sm text-muted-foreground">
285− No identity provider yet. Register one so MCP servers can authorize members through
345+ No identity provider yet. Register one before marking any MCP server as managed by
286346 your organization.
287347 </p>
288348 ) : (
@@ -336,8 +396,8 @@ export function EnterpriseIdentityProviderSection() { · 47 unmodified lines
336396 </div>
337397 <div className="border-t border-border px-4 py-3">
338398 <p className="text-xs text-muted-foreground">
339− Members never see a consent screen for servers pointed at this provider, and
340− access is revoked at the provider, not here.
399+ On a managed server, members connect with their work identity instead of a consent
400+ screen — and access is revoked at your identity provider, not here.
341401 </p>
342402 </div>
343403 </div>
packages/react/src/lib/auth-placements.tsx+21−1
@@ -16,7 +16,10 @@ · 15 unmodified lines
1616 // ---------------------------------------------------------------------------
1717
1818 import { AuthTemplateSlug } from "@executor-js/sdk/shared";
19−import type { AuthMethodDescriptor } from "@executor-js/sdk/shared";
19+import type {
20+ AuthMethodDescriptor,
21+ EnterpriseIdentityProviderDescriptor,
22+} from "@executor-js/sdk/shared";
2023
2124 export type Carrier = "header" | "query" | "env";
2225
@@ -74,6 +77,17 @@ export interface AuthMethodOAuth { · 51 unmodified lines
7477 * `client_id` is this host's metadata document URL, with no provider-side app
7578 * registration. */
7679 readonly supportsClientIdMetadataDocument?: boolean;
80+ /** MCP Enterprise-Managed Authorization: which registered OAuth app stands for
81+ * the organization's identity provider for this server. Present only when the
82+ * server declares one.
83+ *
84+ * A POINTER, and only a pointer — no assertion, no secret. Its presence means
85+ * "this server is enterprise-managed": the connect path may name the
86+ * organization's identity provider on `oauth.start` instead of walking the
87+ * member through per-server consent. It never removes the interactive route,
88+ * because the server still has to advertise the grant profile for the
89+ * enterprise branch to be taken at all. */
90+ readonly enterpriseIdentityProvider?: EnterpriseIdentityProviderDescriptor;
7791 }
7892
7993 export interface AuthMethod {
@@ -177,6 +191,12 @@ function authMethodFromDescriptor(descriptor: AuthMethodDescriptor): AuthMethod · 97 unmodified lines
177191 discoveryUrl: oauth?.discoveryUrl,
178192 supportsDynamicRegistration: oauth?.supportsDynamicRegistration,
179193 supportsClientIdMetadataDocument: oauth?.supportsClientIdMetadataDocument,
194+ // Carried, not re-derived: the deployment declared WHICH app stands for
195+ // the organization's identity provider, and the connect path has to
196+ // name that exact registration on `oauth.start`. Dropping it here would
197+ // silently turn every enterprise-managed server back into an ordinary
198+ // one in the console while the backend still expected the pointer.
199+ enterpriseIdentityProvider: oauth?.enterpriseIdentityProvider,
180200 },
181201 };
182202 }