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
14 type AuthMethodSeed,14 type AuthMethodSeed,
15} from "@executor-js/react/components/auth-method-list-editor";15} from "@executor-js/react/components/auth-method-list-editor";
16import { Badge } from "@executor-js/react/components/badge";16import { Badge } from "@executor-js/react/components/badge";
17import { Label } from "@executor-js/react/components/label";
18import { Switch } from "@executor-js/react/components/switch";
19import { useEnterpriseIdentityProviderDescriptor } from "@executor-js/react/components/enterprise-idp-section";
17import { FormErrorAlert } from "@executor-js/react/lib/integration-add";20import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
18 21
19import { configureMcpAuth, mcpServerAtom } from "./atoms";22import { configureMcpAuth, mcpServerAtom } from "./atoms";
20import type {23import type {
21 McpAuthMethod,24 McpAuthMethod,
22 McpCanonicalAuthMethodInput,25 McpCanonicalAuthMethodInput,
26 McpEnterpriseIdentityProvider,
23 McpIntegrationConfig,27 McpIntegrationConfig,
24} from "../sdk/types";28} from "../sdk/types";
25import {29import {
26 editorValueFromMcpAuthMethod,30 editorValueFromMcpAuthMethod,
27 mcpAuthMethodInputFromEditorValue,31 mcpAuthMethodInputFromEditorValue,
32 mcpOAuthMethodInput,
28 mcpWireAuthInput,33 mcpWireAuthInput,
34 sameEnterpriseIdentityProvider,
29} from "./auth-method-config";35} from "./auth-method-config";
30 36
31type McpServer = {37type McpServer = {
@@ -95,16 +101,44 @@ function RemoteEdit(props: { · 63 unmodified lines
95 101
96 const [error, setError] = useState<string | null>(null);102 const [error, setError] = useState<string | null>(null);
97 103
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
98 // The edited methods, slugs preserved for seeded rows so existing127 // The edited methods, slugs preserved for seeded rows so existing
99 // connections (bound by template slug) stay attached. New rows omit the128 // 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.
101 const editedMethods = useMemo<readonly McpCanonicalAuthMethodInput[]>(134 const editedMethods = useMemo<readonly McpCanonicalAuthMethodInput[]>(
102 () =>135 () =>
103 list.rows.map((row: AuthMethodRow): McpCanonicalAuthMethodInput => {136 list.rows.map((row: AuthMethodRow): McpCanonicalAuthMethodInput => {
104 const input = mcpAuthMethodInputFromEditorValue(row.value);137 const edited = mcpAuthMethodInputFromEditorValue(row.value);
105 return row.seedSlug !== undefined ? { ...input, slug: row.seedSlug } : input;138 const declared = edited.kind === "oauth2" ? mcpOAuthMethodInput(nextProvider) : edited;
139 return row.seedSlug !== undefined ? { ...declared, slug: row.seedSlug } : declared;
106 }),140 }),
107 [list.rows],141 [list.rows, nextProvider],
108 );142 );
109 143
110 const methodsChanged = useMemo(() => {144 const methodsChanged = useMemo(() => {
@@ -118,10 +152,25 @@ function RemoteEdit(props: { · 7 unmodified lines
118 if (method.kind === "apikey" && current.kind === "apikey") {152 if (method.kind === "apikey" && current.kind === "apikey") {
119 return !samePlacements(method.placements, current.placements);153 return !samePlacements(method.placements, current.placements);
120 }154 }
155 if (method.kind === "oauth2" && current.kind === "oauth2") {
156 return !sameEnterpriseIdentityProvider(
157 method.enterpriseIdentityProvider,
158 current.enterpriseIdentityProvider,
159 );
160 }
121 return false;161 return false;
122 });162 });
123 }, [editedMethods, server.config.authenticationTemplate]);163 }, [editedMethods, server.config.authenticationTemplate]);
124 164
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
125 // Staged apply, run by the sheet's Save when the method list changed.174 // Staged apply, run by the sheet's Save when the method list changed.
126 const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => {175 const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => {
127 setError(null);176 setError(null);
@@ -168,6 +217,38 @@ function RemoteEdit(props: { · 40 unmodified lines
168 footerHint="Connections pick one of these methods. Removing a method detaches connections created against it."217 footerHint="Connections pick one of these methods. Removing a method detaches connections created against it."
169 />218 />
170 219
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
171 {error && <FormErrorAlert message={error} />}252 {error && <FormErrorAlert message={error} />}
172 </div>253 </div>
173 );254 );
packages/plugins/mcp/src/react/McpSignInButton.tsx+7−17
@@ -2,18 +2,14 @@ import { useMemo, useState } from "react"; · 1 unmodified line
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4 4
5import {5import { IntegrationSlug, type Connection, type Owner } from "@executor-js/sdk/shared";
6 AuthTemplateSlug,
7 IntegrationSlug,
8 type Connection,
9 type Owner,
10} from "@executor-js/sdk/shared";
11import { connectionsAllAtom } from "@executor-js/react/api/atoms";6import { connectionsAllAtom } from "@executor-js/react/api/atoms";
12import { AddAccountModal } from "@executor-js/react/components/add-account-modal";7import { AddAccountModal } from "@executor-js/react/components/add-account-modal";
13import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in";8import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in";
14import type { AuthMethod } from "@executor-js/react/lib/auth-placements";9import type { AuthMethod } from "@executor-js/react/lib/auth-placements";
15 10
16import { mcpServerAtom } from "./atoms";11import { mcpServerAtom } from "./atoms";
12import { authMethodsFromConfig } from "./auth-method-config";
17import type { McpAuthMethod } from "../sdk/types";13import type { McpAuthMethod } from "../sdk/types";
18 14
19// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
@@ -47,21 +43,15 @@ export default function McpSignInButton(props: { integrationId: string; owner?: · 27 unmodified lines
47 (connection: Connection) => connection.integration === slug,43 (connection: Connection) => connection.integration === slug,
48 );44 );
49 45
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.
50 const methods = useMemo<readonly AuthMethod[]>(50 const methods = useMemo<readonly AuthMethod[]>(
51 () =>51 () =>
52 remote === null || oauthMethod === null52 remote === null || oauthMethod === null
53 ? []53 ? []
54 : [54 : authMethodsFromConfig([oauthMethod], remote.endpoint),
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 ],
65 [remote, oauthMethod],55 [remote, oauthMethod],
66 );56 );
67 const initialState = useMemo(57 const initialState = useMemo(
packages/plugins/mcp/src/react/auth-method-config.test.ts+109−0
@@ -1,4 +1,5 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { OAuthClientSlug } from "@executor-js/sdk/shared";
2import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";3import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
3 4
4import {5import {
@@ -6,6 +7,8 @@ import { · 1 unmodified line
6 editorValueFromMcpAuthMethod,7 editorValueFromMcpAuthMethod,
7 mcpAuthMethodInputFromEditorValue,8 mcpAuthMethodInputFromEditorValue,
8 mcpAuthMethodInputsFromPlacements,9 mcpAuthMethodInputsFromPlacements,
10 mcpOAuthMethodInput,
11 sameEnterpriseIdentityProvider,
9} from "./auth-method-config";12} from "./auth-method-config";
10 13
11describe("mcpAuthMethodInputFromEditorValue", () => {14describe("mcpAuthMethodInputFromEditorValue", () => {
@@ -175,6 +178,112 @@ describe("authMethodsFromConfig", () => { · 163 unmodified lines
175 });178 });
176});179});
177 180
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
192const PROVIDER = {
193 client: OAuthClientSlug.make("enterprise-identity-provider"),
194 clientOwner: "org",
195} as const;
196
197describe("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
227describe("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
245describe("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
261describe("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
178describe("mcpAuthMethodInputsFromPlacements", () => {287describe("mcpAuthMethodInputsFromPlacements", () => {
179 it("builds ONE method carrying every named placement", () => {288 it("builds ONE method carrying every named placement", () => {
180 expect(289 expect(
packages/plugins/mcp/src/react/auth-method-config.ts+51−7
@@ -21,6 +21,8 @@ import type { · 20 unmodified lines
21 McpAuthMethod,21 McpAuthMethod,
22 McpAuthMethodInput,22 McpAuthMethodInput,
23 McpCanonicalAuthMethodInput,23 McpCanonicalAuthMethodInput,
24 McpEnterpriseIdentityProvider,
25 McpOAuthMethod,
24 McpStdioEnvMethod,26 McpStdioEnvMethod,
25} from "../sdk/types";27} from "../sdk/types";
26 28
@@ -48,20 +50,37 @@ export const mcpWireAuthInput = ( · 21 unmodified lines
48 method: McpAuthMethod | McpCanonicalAuthMethodInput,50 method: McpAuthMethod | McpCanonicalAuthMethodInput,
49): McpAuthMethodInput => wireAuthInputFromShared(method) as McpAuthMethodInput;51): McpAuthMethodInput => wireAuthInputFromShared(method) as McpAuthMethodInput;
50 52
51const oauthAuthMethod = (slug: string, endpoint: string): AuthMethod => ({53const oauthAuthMethod = (method: McpOAuthMethod, endpoint: string): AuthMethod => ({
52 id: slug,54 id: method.slug,
53 label: "OAuth",55 label: "OAuth",
54 kind: "oauth",56 kind: "oauth",
55 source: slug.startsWith("custom_") ? "custom" : "spec",57 source: method.slug.startsWith("custom_") ? "custom" : "spec",
56 template: AuthTemplateSlug.make(slug),58 template: AuthTemplateSlug.make(method.slug),
57 placements: [],59 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 },
59});72});
60 73
61/** Convert a generic editor value into one MCP auth-method input (no slug —74/** Convert a generic editor value into one MCP auth-method input (no slug —
62 * the backend assigns carrier-derived slugs). An apikey value keeps every75 * the backend assigns carrier-derived slugs). An apikey value keeps every
63 * named placement (headers and query params mix freely); one with no usable76 * 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. */
65export function mcpAuthMethodInputFromEditorValue(84export function mcpAuthMethodInputFromEditorValue(
66 value: AuthTemplateEditorValue,85 value: AuthTemplateEditorValue,
67): McpCanonicalAuthMethodInput {86): McpCanonicalAuthMethodInput {
@@ -71,6 +90,31 @@ export function mcpAuthMethodInputFromEditorValue( · 3 unmodified lines
71 }) as McpCanonicalAuthMethodInput;90 }) as McpCanonicalAuthMethodInput;
72}91}
73 92
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. */
100export 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. */
110export 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
74/** Convert one stored MCP method into the generic editor value. */118/** Convert one stored MCP method into the generic editor value. */
75export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplateEditorValue {119export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplateEditorValue {
76 if (method.kind === "oauth2") {120 if (method.kind === "oauth2") {
@@ -89,7 +133,7 @@ export function authMethodsFromConfig( · 12 unmodified lines
89 endpoint: string,133 endpoint: string,
90): AuthMethod[] {134): AuthMethod[] {
91 return methods.map((method: McpAuthMethod): AuthMethod => {135 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);
93 if (method.kind === "stdio_env") return stdioEnvAuthMethod(method);137 if (method.kind === "stdio_env") return stdioEnvAuthMethod(method);
94 return authMethodFromSharedTemplate(method);138 return authMethodFromSharedTemplate(method);
95 });139 });
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
4import {4import {
5 ENTERPRISE_IDENTITY_PROVIDER_CLIENT_OWNER,5 ENTERPRISE_IDENTITY_PROVIDER_CLIENT_OWNER,
6 ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG,6 ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG,
7 ENTERPRISE_IDENTITY_PROVIDER_DESCRIPTOR,
7 canSubmitEnterpriseIdentityProvider,8 canSubmitEnterpriseIdentityProvider,
8 enterpriseIdentityProviderPayload,9 enterpriseIdentityProviderPayload,
9 findEnterpriseIdentityProvider,10 findEnterpriseIdentityProvider,
@@ -42,14 +43,20 @@ describe("findEnterpriseIdentityProvider", () => { · 32 unmodified lines
42});43});
43 44
44describe("canSubmitEnterpriseIdentityProvider", () => {45describe("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 };
46 51
47 it("accepts a public client with no secret", () => {52 it("accepts a public client with no secret", () => {
48 expect(canSubmitEnterpriseIdentityProvider(valid)).toBe(true);53 expect(canSubmitEnterpriseIdentityProvider(valid)).toBe(true);
49 });54 });
50 55
51 it("requires the token endpoint the assertion exchange is POSTed to", () => {56 it("requires the issuer the endpoints are discovered from", () => {
52 expect(canSubmitEnterpriseIdentityProvider({ ...valid, tokenUrl: " " })).toBe(false);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);
53 });60 });
54 61
55 it("requires the client id the exchange authenticates as", () => {62 it("requires the client id the exchange authenticates as", () => {
@@ -62,9 +69,14 @@ describe("canSubmitEnterpriseIdentityProvider", () => { · 6 unmodified lines
62});69});
63 70
64describe("enterpriseIdentityProviderPayload", () => {71describe("enterpriseIdentityProviderPayload", () => {
72 const discovered = {
73 authorizationUrl: " https://idp.example/authorize ",
74 tokenUrl: " https://idp.example/token ",
75 };
76
65 it("registers the workspace-owned app under the reserved slug", () => {77 it("registers the workspace-owned app under the reserved slug", () => {
66 const payload = enterpriseIdentityProviderPayload({78 const payload = enterpriseIdentityProviderPayload({
67 tokenUrl: " https://idp.example/token ",79 ...discovered,
68 clientId: " abc ",80 clientId: " abc ",
69 clientSecret: " shh ",81 clientSecret: " shh ",
70 });82 });
@@ -75,26 +87,45 @@ describe("enterpriseIdentityProviderPayload", () => { · 4 unmodified lines
75 expect(payload.clientSecret).toBe("shh");87 expect(payload.clientSecret).toBe("shh");
76 });88 });
77 89
78 it("records no authorization URL, because this app never runs a redirect", () => {90 it("records the authorization endpoint the work-identity link needs", () => {
79 // The enterprise-managed chain exchanges an assertion the member already91 // The ID-JAG exchange itself only touches the token endpoint. The
80 // holds at the token endpoint; there is no authorization endpoint to store,92 // authorization endpoint is here for the hop that produces the subject
81 // and inventing one would claim a flow this registration does not run.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.
82 expect(106 expect(
83 enterpriseIdentityProviderPayload({107 enterpriseIdentityProviderPayload({ ...discovered, clientId: "abc", clientSecret: "" }).grant,
84 tokenUrl: "https://idp.example/token",108 ).toBe("authorization_code");
85 clientId: "abc",
86 clientSecret: "",
87 }).authorizationUrl,
88 ).toBe("");
89 });109 });
90 110
91 it("stamps no originating integration — it backs every managed server", () => {111 it("stamps no originating integration — it backs every managed server", () => {
92 expect(112 expect(
93 enterpriseIdentityProviderPayload({113 enterpriseIdentityProviderPayload({ ...discovered, clientId: "abc", clientSecret: "" })
94 tokenUrl: "https://idp.example/token",114 .originIntegration,
95 clientId: "abc",
96 clientSecret: "",
97 }).originIntegration,
98 ).toBeNull();115 ).toBeNull();
99 });116 });
100});117});
118
119describe("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
2import { useAtomValue, useAtomSet } from "@effect/atom-react";2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
5import { OAuthClientSlug, type OAuthClientSummary, type Owner } from "@executor-js/sdk/shared";5import {
6 OAuthClientSlug,
7 type EnterpriseIdentityProviderDescriptor,
8 type OAuthClientSummary,
9 type Owner,
10} from "@executor-js/sdk/shared";
6import { toast } from "sonner";11import { toast } from "sonner";
7 12
8import {13import {
9 createOAuthClientOptimistic,14 createOAuthClientOptimistic,
10 oauthClientsOptimisticAtom,15 oauthClientsOptimisticAtom,
16 probeOAuth,
11 removeOAuthClientOptimistic,17 removeOAuthClientOptimistic,
12} from "../api/atoms";18} from "../api/atoms";
13import { trackEvent } from "../api/analytics";19import { trackEvent } from "../api/analytics";
@@ -75,31 +81,43 @@ export const findEnterpriseIdentityProvider = ( · 61 unmodified lines
75 String(client.slug) === String(ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG),81 String(client.slug) === String(ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG),
76 ) ?? null;82 ) ?? null;
77 83
78/** Registering the provider needs the token endpoint the RFC 8693 exchange is84/** The descriptor a managed server points at — the shape an MCP server's
79 * POSTed to and the client id it authenticates as. The secret is optional: an85 * `enterpriseIdentityProvider` declaration and `oauth.start`'s `enterprise`
80 * IdP may treat the client as public. */86 * input both speak. Derived from the reserved identity, never typed by hand. */
87export 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. */
81export const canSubmitEnterpriseIdentityProvider = (input: {95export const canSubmitEnterpriseIdentityProvider = (input: {
82 readonly submitting: boolean;96 readonly submitting: boolean;
83 readonly tokenUrl: string;97 readonly issuerUrl: string;
84 readonly clientId: string;98 readonly clientId: string;
85}): boolean =>99}): 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;
87 101
88/** The `oauth.createClient` payload for the organization's identity provider.102/** The `oauth.createClient` payload for the organization's identity provider.
89 *103 *
90 * `authorizationUrl` is empty by construction and that is not an omission: the104 * BOTH endpoints are recorded, and the authorization endpoint is the one worth
91 * enterprise-managed chain never runs a browser redirect through this105 * explaining. The RFC 8693 exchange that mints an ID-JAG only ever touches the
92 * registration. It exchanges an assertion the user already holds at the token106 * token endpoint — but the exchange needs a subject token ISSUED BY THIS
93 * endpoint (draft §4.3), so there is no authorization endpoint to record — and107 * PROVIDER and audienced to THIS client, and a WorkOS-brokered login does not
94 * inventing one would claim a flow this app does not run. */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. */
95export const enterpriseIdentityProviderPayload = (input: {112export const enterpriseIdentityProviderPayload = (input: {
113 readonly authorizationUrl: string;
96 readonly tokenUrl: string;114 readonly tokenUrl: string;
97 readonly clientId: string;115 readonly clientId: string;
98 readonly clientSecret: string;116 readonly clientSecret: string;
99}) => ({117}) => ({
100 owner: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_OWNER,118 owner: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_OWNER,
101 slug: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG,119 slug: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG,
102 authorizationUrl: "",120 authorizationUrl: input.authorizationUrl.trim(),
103 tokenUrl: input.tokenUrl.trim(),121 tokenUrl: input.tokenUrl.trim(),
104 grant: "authorization_code" as const,122 grant: "authorization_code" as const,
105 clientId: input.clientId.trim(),123 clientId: input.clientId.trim(),
@@ -109,6 +127,22 @@ export const enterpriseIdentityProviderPayload = (input: { · 3 unmodified lines
109 originIntegration: null,127 originIntegration: null,
110});128});
111 129
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 */
138export 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
112// ---------------------------------------------------------------------------146// ---------------------------------------------------------------------------
113// The registration dialog. Self-contained: its form state lives here and dies147// The registration dialog. Self-contained: its form state lives here and dies
114// with it, so re-opening after a cancel never resurfaces a half-typed secret.148// with it, so re-opening after a cancel never resurfaces a half-typed secret.
@@ -122,19 +156,36 @@ function EnterpriseIdentityProviderDialog(props: { · 7 unmodified lines
122 readonly current: OAuthClientSummary | null;156 readonly current: OAuthClientSummary | null;
123}) {157}) {
124 const { current } = props;158 const { current } = props;
125 const [tokenUrl, setTokenUrl] = useState(current === null ? "" : current.tokenUrl);159 const [issuerUrl, setIssuerUrl] = useState(current === null ? "" : current.tokenUrl);
126 const [clientId, setClientId] = useState(current === null ? "" : current.clientId);160 const [clientId, setClientId] = useState(current === null ? "" : current.clientId);
127 const [clientSecret, setClientSecret] = useState("");161 const [clientSecret, setClientSecret] = useState("");
128 const [submitting, setSubmitting] = useState(false);162 const [submitting, setSubmitting] = useState(false);
129 const doCreate = useAtomSet(createOAuthClientOptimistic, { mode: "promiseExit" });163 const doCreate = useAtomSet(createOAuthClientOptimistic, { mode: "promiseExit" });
164 const doProbe = useAtomSet(probeOAuth, { mode: "promiseExit" });
130 165
131 const canSubmit = canSubmitEnterpriseIdentityProvider({ submitting, tokenUrl, clientId });166 const canSubmit = canSubmitEnterpriseIdentityProvider({ submitting, issuerUrl, clientId });
132 167
133 const handleSubmit = async () => {168 const handleSubmit = async () => {
134 if (!canSubmit) return;169 if (!canSubmit) return;
135 setSubmitting(true);170 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 }
136 const exit = await doCreate({182 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 }),
138 reactivityKeys: oauthClientWriteKeys,189 reactivityKeys: oauthClientWriteKeys,
139 });190 });
140 trackEvent("oauth_client_registered", {191 trackEvent("oauth_client_registered", {
@@ -145,7 +196,7 @@ function EnterpriseIdentityProviderDialog(props: { · 4 unmodified lines
145 });196 });
146 if (Exit.isFailure(exit)) {197 if (Exit.isFailure(exit)) {
147 setSubmitting(false);198 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.");
149 return;200 return;
150 }201 }
151 toast.success(current === null ? "Identity provider registered" : "Identity provider updated");202 toast.success(current === null ? "Identity provider registered" : "Identity provider updated");
@@ -166,23 +217,27 @@ function EnterpriseIdentityProviderDialog(props: { · 14 unmodified lines
166 {current === null ? "Register Identity Provider" : "Edit Identity Provider"}217 {current === null ? "Register Identity Provider" : "Edit Identity Provider"}
167 </DialogTitle>218 </DialogTitle>
168 <DialogDescription className="text-sm leading-relaxed">219 <DialogDescription className="text-sm leading-relaxed">
169 Executor exchanges each member&apos;s single sign-on assertion at this endpoint for a220 Register Executor as an application in your own identity provider, then paste its
170 grant naming one MCP server. Your identity provider decides which members may reach221 details here. Executor exchanges each member&apos;s work identity there for a grant
171 which servers.222 naming one MCP server, so your identity provider — not the member — decides who reaches
223 which server.
172 </DialogDescription>224 </DialogDescription>
173 </DialogHeader>225 </DialogHeader>
174 226
175 <div className="grid gap-4 py-1">227 <div className="grid gap-4 py-1">
176 <div className="grid gap-1.5">228 <div className="grid gap-1.5">
177 <Label htmlFor="ema-idp-token-url" className="text-xs text-muted-foreground">229 <Label htmlFor="ema-idp-issuer-url" className="text-xs text-muted-foreground">
178 Token URL230 Issuer URL
231 <span className="font-normal text-muted-foreground/70">
232 endpoints are read from it
233 </span>
179 </Label>234 </Label>
180 <Input235 <Input
181 id="ema-idp-token-url"236 id="ema-idp-issuer-url"
182 autoComplete="off"237 autoComplete="off"
183 placeholder="https://example.okta.com/oauth2/default/v1/token"238 placeholder="https://example.okta.com/oauth2/default"
184 value={tokenUrl}239 value={issuerUrl}
185 onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTokenUrl(e.target.value)}240 onChange={(e: React.ChangeEvent<HTMLInputElement>) => setIssuerUrl(e.target.value)}
186 className="font-mono text-sm"241 className="font-mono text-sm"
187 />242 />
188 </div>243 </div>
@@ -226,7 +281,11 @@ function EnterpriseIdentityProviderDialog(props: { · 37 unmodified lines
226 </Button>281 </Button>
227 </DialogClose>282 </DialogClose>
228 <Button size="sm" onClick={() => void handleSubmit()} disabled={!canSubmit}>283 <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"}
230 </Button>289 </Button>
231 </DialogFooter>290 </DialogFooter>
232 </DialogContent>291 </DialogContent>
@@ -261,8 +320,9 @@ export function EnterpriseIdentityProviderSection() { · 28 unmodified lines
261 <div>320 <div>
262 <h2 className="text-sm font-medium text-foreground">Enterprise Identity Provider</h2>321 <h2 className="text-sm font-medium text-foreground">Enterprise Identity Provider</h2>
263 <p className="mt-0.5 text-sm text-muted-foreground">322 <p className="mt-0.5 text-sm text-muted-foreground">
264 Register the app Executor uses at your identity provider. MCP servers pointed at it323 Register Executor&apos;s application from your own identity provider. MCP servers marked
265 connect through your organization instead of asking each member for consent.324 as managed then connect with each member&apos;s work identity, instead of asking them to
325 consent server by server.
266 </p>326 </p>
267 </div>327 </div>
268 {provider === null ? (328 {provider === null ? (
@@ -282,7 +342,7 @@ export function EnterpriseIdentityProviderSection() { · 13 unmodified lines
282 onSuccess: () =>342 onSuccess: () =>
283 provider === null ? (343 provider === null ? (
284 <p className="py-6 text-center text-sm text-muted-foreground">344 <p className="py-6 text-center text-sm text-muted-foreground">
285 No identity provider yet. Register one so MCP servers can authorize members through345 No identity provider yet. Register one before marking any MCP server as managed by
286 your organization.346 your organization.
287 </p>347 </p>
288 ) : (348 ) : (
@@ -336,8 +396,8 @@ export function EnterpriseIdentityProviderSection() { · 47 unmodified lines
336 </div>396 </div>
337 <div className="border-t border-border px-4 py-3">397 <div className="border-t border-border px-4 py-3">
338 <p className="text-xs text-muted-foreground">398 <p className="text-xs text-muted-foreground">
339 Members never see a consent screen for servers pointed at this provider, and399 On a managed server, members connect with their work identity instead of a consent
340 access is revoked at the provider, not here.400 screen — and access is revoked at your identity provider, not here.
341 </p>401 </p>
342 </div>402 </div>
343 </div>403 </div>
packages/react/src/lib/auth-placements.tsx+21−1
@@ -16,7 +16,10 @@ · 15 unmodified lines
16// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
17 17
18import { AuthTemplateSlug } from "@executor-js/sdk/shared";18import { AuthTemplateSlug } from "@executor-js/sdk/shared";
19import type { AuthMethodDescriptor } from "@executor-js/sdk/shared";19import type {
20 AuthMethodDescriptor,
21 EnterpriseIdentityProviderDescriptor,
22} from "@executor-js/sdk/shared";
20 23
21export type Carrier = "header" | "query" | "env";24export type Carrier = "header" | "query" | "env";
22 25
@@ -74,6 +77,17 @@ export interface AuthMethodOAuth { · 51 unmodified lines
74 * `client_id` is this host's metadata document URL, with no provider-side app77 * `client_id` is this host's metadata document URL, with no provider-side app
75 * registration. */78 * registration. */
76 readonly supportsClientIdMetadataDocument?: boolean;79 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;
77}91}
78 92
79export interface AuthMethod {93export interface AuthMethod {
@@ -177,6 +191,12 @@ function authMethodFromDescriptor(descriptor: AuthMethodDescriptor): AuthMethod · 97 unmodified lines
177 discoveryUrl: oauth?.discoveryUrl,191 discoveryUrl: oauth?.discoveryUrl,
178 supportsDynamicRegistration: oauth?.supportsDynamicRegistration,192 supportsDynamicRegistration: oauth?.supportsDynamicRegistration,
179 supportsClientIdMetadataDocument: oauth?.supportsClientIdMetadataDocument,193 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,
180 },200 },
181 };201 };
182 }202 }