Unified · Split
| @@ -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"; |
| 16 | import { Badge } from "@executor-js/react/components/badge"; | 16 | 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"; | ||
| 17 | import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; | 20 | import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; |
| 18 | 21 | ||
| 19 | import { configureMcpAuth, mcpServerAtom } from "./atoms"; | 22 | import { configureMcpAuth, mcpServerAtom } from "./atoms"; |
| 20 | import type { | 23 | import type { |
| 21 | McpAuthMethod, | 24 | McpAuthMethod, |
| 22 | McpCanonicalAuthMethodInput, | 25 | McpCanonicalAuthMethodInput, |
| 26 | McpEnterpriseIdentityProvider, | ||
| 23 | McpIntegrationConfig, | 27 | McpIntegrationConfig, |
| 24 | } from "../sdk/types"; | 28 | } from "../sdk/types"; |
| 25 | import { | 29 | import { |
| 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 | ||
| 31 | type McpServer = { | 37 | type 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 existing | 127 | // The edited methods, slugs preserved for seeded rows so existing |
| 99 | // connections (bound by template slug) stay attached. New rows omit the | 128 | // 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 | ); |
| @@ -2,18 +2,14 @@ import { useMemo, useState } from "react"; · 1 unmodified line | |||
| 2 | import { useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { 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"; | ||
| 11 | import { connectionsAllAtom } from "@executor-js/react/api/atoms"; | 6 | import { connectionsAllAtom } from "@executor-js/react/api/atoms"; |
| 12 | import { AddAccountModal } from "@executor-js/react/components/add-account-modal"; | 7 | import { AddAccountModal } from "@executor-js/react/components/add-account-modal"; |
| 13 | import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; | 8 | import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; |
| 14 | import type { AuthMethod } from "@executor-js/react/lib/auth-placements"; | 9 | import type { AuthMethod } from "@executor-js/react/lib/auth-placements"; |
| 15 | 10 | ||
| 16 | import { mcpServerAtom } from "./atoms"; | 11 | import { mcpServerAtom } from "./atoms"; |
| 12 | import { authMethodsFromConfig } from "./auth-method-config"; | ||
| 17 | import type { McpAuthMethod } from "../sdk/types"; | 13 | import 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 === null | 52 | 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( |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { OAuthClientSlug } from "@executor-js/sdk/shared"; | ||
| 2 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; | 3 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; |
| 3 | 4 | ||
| 4 | import { | 5 | import { |
| @@ -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 | ||
| 11 | describe("mcpAuthMethodInputFromEditorValue", () => { | 14 | describe("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 | |||
| 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 | |||
| 178 | describe("mcpAuthMethodInputsFromPlacements", () => { | 287 | describe("mcpAuthMethodInputsFromPlacements", () => { |
| 179 | it("builds ONE method carrying every named placement", () => { | 288 | it("builds ONE method carrying every named placement", () => { |
| 180 | expect( | 289 | expect( |
| @@ -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 | ||
| 51 | const oauthAuthMethod = (slug: string, endpoint: string): AuthMethod => ({ | 53 | const 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 every | 75 | * the backend assigns carrier-derived slugs). An apikey value keeps every |
| 63 | * named placement (headers and query params mix freely); one with no usable | 76 | * 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. */ | ||
| 65 | export function mcpAuthMethodInputFromEditorValue( | 84 | export 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. */ | ||
| 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 | |||
| 74 | /** Convert one stored MCP method into the generic editor value. */ | 118 | /** Convert one stored MCP method into the generic editor value. */ |
| 75 | export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplateEditorValue { | 119 | export 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 | }); |
| @@ -4,6 +4,7 @@ import { OAuthClientSlug, type OAuthClientSummary } from "@executor-js/sdk/share · 3 unmodified lines | |||
| 4 | import { | 4 | import { |
| 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 | ||
| 44 | describe("canSubmitEnterpriseIdentityProvider", () => { | 45 | 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 | }; | ||
| 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 | ||
| 64 | describe("enterpriseIdentityProviderPayload", () => { | 71 | describe("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 already | 91 | // 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 | |||
| 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 | }); | ||
| @@ -2,12 +2,18 @@ import { useState } from "react"; · 1 unmodified line | |||
| 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | import * as Exit from "effect/Exit"; | 4 | 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"; | ||
| 6 | import { toast } from "sonner"; | 11 | import { toast } from "sonner"; |
| 7 | 12 | ||
| 8 | import { | 13 | import { |
| 9 | createOAuthClientOptimistic, | 14 | createOAuthClientOptimistic, |
| 10 | oauthClientsOptimisticAtom, | 15 | oauthClientsOptimisticAtom, |
| 16 | probeOAuth, | ||
| 11 | removeOAuthClientOptimistic, | 17 | removeOAuthClientOptimistic, |
| 12 | } from "../api/atoms"; | 18 | } from "../api/atoms"; |
| 13 | import { trackEvent } from "../api/analytics"; | 19 | import { 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 is | 84 | /** 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: an | 85 | * `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. */ |
| 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. */ | ||
| 81 | export const canSubmitEnterpriseIdentityProvider = (input: { | 95 | export 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: the | 104 | * BOTH endpoints are recorded, and the authorization endpoint is the one worth |
| 91 | * enterprise-managed chain never runs a browser redirect through this | 105 | * 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 token | 106 | * 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 — and | 107 | * 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. */ | ||
| 95 | export const enterpriseIdentityProviderPayload = (input: { | 112 | export 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 | */ | ||
| 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 | |||
| 112 | // --------------------------------------------------------------------------- | 146 | // --------------------------------------------------------------------------- |
| 113 | // The registration dialog. Self-contained: its form state lives here and dies | 147 | // 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's single sign-on assertion at this endpoint for a | 220 | 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 reach | 221 | details here. Executor exchanges each member'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 URL | 230 | Issuer URL |
| 231 | <span className="font-normal text-muted-foreground/70"> | ||
| 232 | endpoints are read from it | ||
| 233 | </span> | ||
| 179 | </Label> | 234 | </Label> |
| 180 | <Input | 235 | <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 it | 323 | Register Executor'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'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 through | 345 | 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, and | 399 | 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> |
| @@ -16,7 +16,10 @@ · 15 unmodified lines | |||
| 16 | // --------------------------------------------------------------------------- | 16 | // --------------------------------------------------------------------------- |
| 17 | 17 | ||
| 18 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | 18 | 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"; | ||
| 20 | 23 | ||
| 21 | export type Carrier = "header" | "query" | "env"; | 24 | export 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 app | 77 | * `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 | ||
| 79 | export interface AuthMethod { | 93 | export 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 | } |