diff --git a/apps/cloud/src/routes/app/org.tsx b/apps/cloud/src/routes/app/org.tsx index a9ded0b7c8..9047cd09a3 100644 --- a/apps/cloud/src/routes/app/org.tsx +++ b/apps/cloud/src/routes/app/org.tsx @@ -28,6 +28,7 @@ import { DropdownMenuTrigger, } from "@executor-js/react/components/dropdown-menu"; import { orgMembersAtom } from "@executor-js/react/api/account-atoms"; +import { EnterpriseIdentityProviderSection } from "@executor-js/react/components/enterprise-idp-section"; import { OrgPage as SharedOrgPage } from "@executor-js/react/pages/org"; import { orgDomainsAtom, getDomainVerificationLink, deleteDomain } from "../../web/org-atoms"; import { deleteOrganization, useAuth } from "../../web/auth"; @@ -61,6 +62,7 @@ function OrgPage() { {/* Shared members / roles / invite / org-name surface. */} } + enterpriseIdpSection={} upgradeAction={ @@ -72,13 +74,38 @@ function OrgPage() { ); } +/** Whether the caller administers this organization, derived from the members + * list already loaded for this page. False while it loads, so an admin-only + * control never flashes for someone who cannot use it. + * + * CLIENT-SIDE UX GATING ONLY: every endpoint behind these controls enforces + * its own authorization. Hiding a control the server would refuse is a + * courtesy, not the boundary. */ +function useIsOrgAdmin(): boolean { + const membersResult = useAtomValue(orgMembersAtom); + return AsyncResult.match(membersResult, { + onInitial: () => false, + onFailure: () => false, + onSuccess: ({ value }) => + value.members.some((m) => m.isCurrentUser && m.status === "active" && m.role === "admin"), + }); +} + +// The organization's enterprise identity provider (MCP Enterprise-Managed +// Authorization). Admin-only, on the same gate as the danger zone: registering +// it decides how every member of the workspace authorizes to enterprise-managed +// MCP servers, which is an administrator's call, not a member's. +function EnterpriseIdpSection() { + return useIsOrgAdmin() ? : null; +} + // Destructive org teardown, admin-only. Hidden entirely for non-admins (the // backend enforces admin + name-confirmation regardless). Deleting the org // removes the workspace and all of its data for every member, cancels billing, // and logs the caller out. function DangerZoneSection() { const auth = useAuth(); - const membersResult = useAtomValue(orgMembersAtom); + const isAdmin = useIsOrgAdmin(); const doDelete = useAtomSet(deleteOrganization, { mode: "promiseExit" }); const [open, setOpen] = useState(false); const [confirmText, setConfirmText] = useState(""); @@ -86,16 +113,6 @@ function DangerZoneSection() { const organizationName = auth.status === "authenticated" ? auth.organization?.name : undefined; - // Only admins may delete. Derive the caller's role from the members list - // (already loaded for this page); render nothing while it loads or for - // members, so a delete control never flashes for someone who can't use it. - const isAdmin = AsyncResult.match(membersResult, { - onInitial: () => false, - onFailure: () => false, - onSuccess: ({ value }) => - value.members.some((m) => m.isCurrentUser && m.status === "active" && m.role === "admin"), - }); - if (!isAdmin || !organizationName) return null; const confirmed = confirmText.trim() === organizationName.trim(); diff --git a/e2e/selfhost/mcp-enterprise-managed-console.test.ts b/e2e/selfhost/mcp-enterprise-managed-console.test.ts new file mode 100644 index 0000000000..620032e42b --- /dev/null +++ b/e2e/selfhost/mcp-enterprise-managed-console.test.ts @@ -0,0 +1,508 @@ +// Selfhost (browser, recorded): the CONSOLE half of MCP Enterprise-Managed +// Authorization. The protocol half — the ID-JAG chain itself and the +// administrator denial — is `mcp-enterprise-managed-auth.test.ts`; this +// scenario is about what an administrator and a member can see and do. +// +// Three claims, in the order a workspace meets them: +// +// 1. An administrator marks a server as managed FROM THE UI, and that +// declaration survives `configureMcpAuth`. It is written through the same +// replace-mode save that rewrites the whole auth-method list, so a +// declaration the credential editor cannot express is one unrelated edit +// away from being erased — the assertion below is that it is not. +// 2. A member's managed connection is visibly managed and offers NO local +// revocation. Remove would claim a revocation that did not happen (the +// identity provider still authorizes them, and the next call hands access +// straight back), and Reconnect re-runs a consent step this profile does +// not have. Both belong at the identity provider. +// 3. An administrator's denial stops the connect and does NOT fall back to +// the interactive per-server flow. The MCP emulator's ledger is the proof: +// it shows the requests executor did NOT make. +// +// Two emulators stand in for the pilot's real parties: `okta` is the customer's +// identity provider (it runs the real OIDC sign-on, mints ID-JAGs over RFC 8693 +// and enforces an administrator policy table), `mcp` is the third-party server +// and its Resource Authorization Server. +// +// GAP, deliberately not papered over: minting the connection below goes through +// the typed API, not the console, because `oauth.start`'s `enterprise` input +// requires the caller to HOLD the identity assertion and no browser surface can +// obtain one. See the branch's report; the console work here is everything +// around that one leg. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:net"; + +import { assert, expect } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; +import type { Page } from "playwright"; +import { composePluginApi } from "@executor-js/api/server"; +import { createEmulator, type Emulator } from "@executor-js/emulate"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +const OKTA_USER = "testuser@okta.local"; +const OKTA_AUTH_SERVER = "default"; +const SSO_REDIRECT_URI = "http://localhost:3000/callback"; +const ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"; + +// The reserved (owner, slug) the organization's identity provider is registered +// under — the SAME identity the org settings section writes, so a server marked +// managed in the console names exactly this app. Self-host has no organization +// admin page, so the registration is seeded through the typed API here; the +// browser drives everything downstream of it. +const IDP_CLIENT = OAuthClientSlug.make("enterprise-identity-provider"); + +const MANAGED_BADGE = "Managed by your organization"; + +const freshSlug = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`; + +const availablePort = Effect.callback((resume) => { + const probe = createServer(); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address(); + const port = typeof address === "object" && address ? address.port : 0; + probe.close(() => { + resume(Effect.succeed(port)); + }); + }); +}); + +/** A locally spawned emulator on an OS-assigned port, closed with the scope. + * Local rather than hosted: this scenario depends on behavior that shipped in + * `@executor-js/emulate` 0.14.0 (Okta minting ID-JAGs), and the npm package is + * the version this checkout pins. */ +const emulator = (service: "okta" | "mcp") => + Effect.acquireRelease( + Effect.gen(function* () { + const port = yield* availablePort; + return yield* Effect.promise(() => createEmulator({ service, port })); + }), + (instance: Emulator) => Effect.promise(() => instance.close()).pipe(Effect.ignore), + ); + +const requireString = (value: string | undefined | null, what: string): string => { + if (!value) throw new Error(`emulator returned no ${what}`); + return value; +}; + +/** Single sign-on at the identity provider, ending with the ID token the host + * holds on the member's behalf. THE one step this console cannot yet perform + * for itself — see the header. */ +const singleSignOn = (input: { + readonly issuerBaseUrl: string; + readonly clientId: string; + readonly clientSecret: string; +}) => + Effect.promise(async (): Promise => { + const authorize = await fetch( + `${input.issuerBaseUrl}/oauth2/${OKTA_AUTH_SERVER}/v1/authorize/callback`, + { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + redirect: "manual", + body: new URLSearchParams({ + user_ref: OKTA_USER, + redirect_uri: SSO_REDIRECT_URI, + scope: "openid profile email", + client_id: input.clientId, + response_mode: "query", + auth_server_id: OKTA_AUTH_SERVER, + }), + }, + ); + if (authorize.status !== 302) { + throw new Error(`IdP authorize answered ${authorize.status}, expected a 302`); + } + const location = requireString(authorize.headers.get("location"), "authorize redirect"); + const code = requireString(new URL(location).searchParams.get("code"), "authorization code"); + + const token = await fetch(`${input.issuerBaseUrl}/oauth2/${OKTA_AUTH_SERVER}/v1/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: SSO_REDIRECT_URI, + client_id: input.clientId, + client_secret: input.clientSecret, + }), + }); + if (!token.ok) throw new Error(`IdP token endpoint answered ${token.status}`); + const body = (await token.json()) as { readonly id_token?: string }; + return requireString(body.id_token, "id_token"); + }); + +const connectionsSection = (page: Page) => + page.locator("section").filter({ + has: page.getByRole("heading", { level: 3, name: "Connections" }), + }); + +const callGetMeCode = (slug: string, connection: string) => ` +const result = await tools.${slug}.org.${connection}.get_me({}); +return { ok: result.ok, payload: result.ok ? result.data : result.error }; +`; + +scenario( + "MCP enterprise-managed authorization (console) · an administrator marks a server managed, and the managed connection offers no local revocation", + { timeout: 240_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeApiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + + const okta = yield* emulator("okta"); + const mcp = yield* emulator("mcp"); + const mcpEndpoint = `${mcp.url}/mcp`; + + // One client identity across both registrations (draft §5 client + // continuity): the app the member signs in to at the identity provider is + // the app that presents itself to the Resource Authorization Server. + const credential = yield* Effect.promise(() => + okta.credentials.mint({ + type: "oauth-authorization-code", + name: "Executor E2E enterprise client", + redirect_uris: [SSO_REDIRECT_URI], + }), + ); + const clientId = requireString(credential.client_id, "IdP client_id"); + const clientSecret = requireString(credential.client_secret, "IdP client_secret"); + const idpTokenUrl = requireString(credential.token_url, "IdP token endpoint"); + const idpAuthorizationUrl = requireString(credential.authorization_url, "IdP authorize URL"); + + const subjectToken = yield* singleSignOn({ + issuerBaseUrl: okta.url, + clientId, + clientSecret, + }); + + const integration = IntegrationSlug.make(freshSlug("mcp_ema_ui")); + const serverClient = OAuthClientSlug.make(freshSlug("ema_server")); + const template = AuthTemplateSlug.make("oauth2"); + const managedConnection = ConnectionName.make("main"); + + // The server starts life ORDINARY — a plain oauth2 MCP server with no + // enterprise declaration. Marking it managed is the browser's job below, + // which is the whole point: a declaration that only ever arrives through + // `addServer` would never exercise the save path that can erase it. + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "Enterprise-managed MCP (emulate)", + endpoint: mcpEndpoint, + slug: String(integration), + authenticationTemplate: [{ kind: "oauth2" }], + }, + }); + + yield* Effect.ensuring( + Effect.gen(function* () { + // The organization's identity provider, under the reserved identity + // the org settings section owns. Both endpoints are recorded exactly + // as that section records them. + yield* client.oauth.createClient({ + payload: { + owner: "org", + slug: IDP_CLIENT, + authorizationUrl: idpAuthorizationUrl, + tokenUrl: idpTokenUrl, + grant: "authorization_code", + clientId, + clientSecret, + }, + }); + // The server-side registration an administrator makes through the + // OAuth app form's "Enterprise identity assertion" grant: `id_jag` + // plus the RFC 9728 resource discovery starts from. + yield* client.oauth.createClient({ + payload: { + owner: "org", + slug: serverClient, + authorizationUrl: `${mcp.url}/authorize`, + tokenUrl: `${mcp.url}/token`, + grant: "id_jag", + clientId, + clientSecret, + resource: mcpEndpoint, + }, + }); + + // ----------------------------------------------------------------- + // 1. The administrator marks the server managed, in the console. + // ----------------------------------------------------------------- + yield* browser.session(identity, async ({ page, step }) => { + await step("An administrator opens the MCP server they want to manage", async () => { + await visit(page, `/integrations/${String(integration)}`); + await page.getByRole("button", { name: "Edit" }).first().waitFor({ timeout: 30_000 }); + }); + + await step("Turn on 'Managed by your organization'", async () => { + await page.getByRole("button", { name: "Edit" }).first().click(); + const toggle = page.locator("#ema-managed-server"); + await toggle.waitFor({ timeout: 30_000 }); + expect( + await toggle.getAttribute("data-state"), + "an ordinary server starts unmanaged", + ).toBe("unchecked"); + await toggle.click(); + // Waited for on the DOM rather than read once: the switch + // animates, and this step's screenshot should show the state the + // administrator sees, not a frame mid-transition. + await page + .locator('#ema-managed-server[data-state="checked"]') + .waitFor({ timeout: 10_000 }); + await page + .locator('#ema-managed-server [data-slot="switch-thumb"][data-state="checked"]') + .waitFor({ timeout: 10_000 }); + }); + + await step("Save — the declaration is written onto the server", async () => { + await page.getByRole("button", { name: "Save" }).click(); + await page + .getByText("Authentication methods updated.", { exact: true }) + .waitFor({ timeout: 30_000 }); + }); + + await step("Reopening the server shows it is still managed", async () => { + // Not a repeat of the save assertion: this reads the toggle back + // from the SERVER's stored declaration on a fresh mount, which is + // the state a second administrator would arrive at. + await page.getByRole("button", { name: "Edit" }).first().click(); + const reopened = page.locator('#ema-managed-server[data-state="checked"]'); + await reopened.waitFor({ timeout: 30_000 }); + // Left open, and scrolled to: this step's screenshot is the + // artifact showing the stored declaration as an administrator + // finds it. + await reopened.scrollIntoViewIfNeeded(); + }); + }); + + // The declaration reached storage AND survived the replace-mode save + // that rewrote the whole method list. Read back through the catalog, + // which is where every connect path learns of it. + const catalog = yield* client.integrations.get({ params: { slug: integration } }); + const declared = catalog.authMethods.find((method) => method.kind === "oauth"); + expect( + declared?.oauth?.enterpriseIdentityProvider, + "the console's toggle declared the organization's identity provider on this server", + ).toEqual({ client: String(IDP_CLIENT), clientOwner: "org" }); + expect( + declared?.oauth?.supportsDynamicRegistration, + "declaring an identity provider leaves the interactive route advertised", + ).toBe(true); + const enterprise = declared?.oauth?.enterpriseIdentityProvider; + assert(enterprise, "the connect below drives off the projected pointer"); + + // ----------------------------------------------------------------- + // 2. A member connects with their work identity, and uses the server. + // This leg goes through the typed API — see the file header. + // ----------------------------------------------------------------- + const connected = yield* client.oauth.start({ + payload: { + owner: "org", + client: serverClient, + clientOwner: "org", + name: managedConnection, + integration, + template, + enterprise: { + idpClient: enterprise.client, + idpClientOwner: enterprise.clientOwner, + subjectToken, + subjectTokenType: ID_TOKEN_TYPE, + }, + }, + }); + assert( + connected.status === "connected", + "the enterprise grant connects with no authorize redirect", + ); + expect( + connected.connection.enterpriseManaged, + "the connection reports itself managed, which is what the console branches on", + ).toBe(true); + + const executed = yield* client.executions.execute({ + payload: { + code: callGetMeCode(String(integration), String(managedConnection)), + autoApprove: true, + }, + }); + expect(executed.status, "the tool call completed").toBe("completed"); + expect((JSON.parse(executed.text) as { readonly ok: boolean }).ok, executed.text).toBe( + true, + ); + + // ----------------------------------------------------------------- + // 3. The member's view of the managed connection. + // ----------------------------------------------------------------- + yield* browser.session(identity, async ({ page, step }) => { + const connections = connectionsSection(page); + const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first(); + + await step("A member opens the managed server's connections", async () => { + await visit(page, `/integrations/${String(integration)}`); + await connections + .getByText(String(managedConnection), { exact: true }) + .waitFor({ timeout: 30_000 }); + }); + + await step("The connection is visibly managed by the organization", async () => { + await connections.getByText(MANAGED_BADGE, { exact: true }).waitFor({ + timeout: 30_000, + }); + // The badge is a claim; this is the consequence of it, said in + // words the member can act on. + await connections + .getByText(/Revoke access at your identity provider, not here\./) + .waitFor({ timeout: 30_000 }); + }); + + await step("Its menu offers no Remove and no Reconnect", async () => { + await menuTrigger.click(); + // Present: the actions that are still the member's to take. + await page.getByRole("menuitem", { name: "Check now" }).waitFor({ timeout: 30_000 }); + await page.getByRole("menuitem", { name: "Edit" }).waitFor({ timeout: 30_000 }); + // Absent: WITHHELD, not disabled. A local Remove would claim a + // revocation that did not happen, and Reconnect re-runs a consent + // step this profile does not have. + expect( + await page.getByRole("menuitem", { name: "Remove" }).count(), + "an enterprise-managed connection cannot be removed locally", + ).toBe(0); + expect( + await page.getByRole("menuitem", { name: "Reconnect" }).count(), + "an enterprise-managed connection has no interactive flow to re-run", + ).toBe(0); + // Left open on purpose: this step's screenshot is the artifact + // that shows the menu as the member sees it. + }); + }); + + // ----------------------------------------------------------------- + // 4. The administrator denies this client at the identity provider. + // Clear both ledgers first so every entry below belongs to the + // blocked attempt. + // ----------------------------------------------------------------- + yield* Effect.promise(() => okta.ledger.clear()); + yield* Effect.promise(() => mcp.ledger.clear()); + yield* Effect.promise(() => + okta.seed({ + token_exchange_policies: [ + { name: "Block the executor client", client_id: clientId, effect: "DENY" }, + ], + }), + ); + + const blocked = yield* client.oauth + .start({ + payload: { + owner: "org", + client: serverClient, + clientOwner: "org", + name: ConnectionName.make("blocked"), + integration, + template, + enterprise: { + idpClient: enterprise.client, + idpClientOwner: enterprise.clientOwner, + subjectToken, + subjectTokenType: ID_TOKEN_TYPE, + }, + }, + }) + .pipe(Effect.flip); + + assert( + Predicate.isTagged(blocked, "OAuthStartError"), + "a policy denial is a start failure, not a transport or decoding fault", + ); + // The two fields the console branches on. It must never decide this + // from the wording of a message: getting it wrong means offering the + // interactive flow, which walks the member around the control the + // identity provider just exercised. + expect(blocked.blockedByAdmin, "the denial reaches the console as a FIELD").toBe(true); + expect( + blocked.oauthErrorCode, + "the provider's own code travels structurally, so support can trace it", + ).toBe("invalid_target"); + + // THE anti-fallback claim, proven by absence: had executor quietly + // offered the ordinary per-server flow, the MCP server would have seen + // an authorize request, a registration, or another redemption. + const afterDenial = yield* Effect.promise(() => mcp.ledger.list()); + expect( + afterDenial.filter( + (entry) => + entry.path === "/authorize" || + entry.path === "/register" || + entry.operationId === "mcp.oauth.jwtBearer", + ), + "a policy denial does not fall back to interactive OAuth", + ).toEqual([]); + + const connections = yield* client.connections.list({ query: { integration } }); + expect( + connections.map((connection) => String(connection.name)).sort(), + "the blocked attempt minted no connection", + ).toEqual([String(managedConnection)]); + + // ----------------------------------------------------------------- + // 5. The denial changes nothing the console offers: the managed row + // still has no route around the identity provider's decision. + // ----------------------------------------------------------------- + yield* browser.session(identity, async ({ page, step }) => { + const rows = connectionsSection(page); + await step("After the denial, the console still offers no way around it", async () => { + await visit(page, `/integrations/${String(integration)}`); + await rows + .getByText(String(managedConnection), { exact: true }) + .waitFor({ timeout: 30_000 }); + await rows.getByText(MANAGED_BADGE, { exact: true }).waitFor({ timeout: 30_000 }); + await rows.locator('button[aria-haspopup="menu"]').first().click(); + // Wait for a menu item that IS there before counting the ones + // that are not: an unopened menu would make every absence + // assertion below pass for the wrong reason. + await page.getByRole("menuitem", { name: "Check now" }).waitFor({ timeout: 30_000 }); + expect( + await page.getByRole("menuitem", { name: "Reconnect" }).count(), + "the interactive route is exactly what the identity provider closed", + ).toBe(0); + expect( + await page.getByRole("menuitem", { name: "Remove" }).count(), + "and a denial does not turn revocation into a local action either", + ).toBe(0); + }); + }); + }), + Effect.gen(function* () { + yield* client.connections + .remove({ + params: { owner: "org", integration, name: managedConnection }, + }) + .pipe(Effect.ignore); + yield* client.oauth + .removeClient({ params: { slug: serverClient }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* client.oauth + .removeClient({ params: { slug: IDP_CLIENT }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* client.mcp.removeServer({ params: { slug: integration } }).pipe(Effect.ignore); + }), + ); + }), + ), +); diff --git a/packages/core/api/src/connections/api.ts b/packages/core/api/src/connections/api.ts index c93e983cb3..6c40512bdc 100644 --- a/packages/core/api/src/connections/api.ts +++ b/packages/core/api/src/connections/api.ts @@ -62,6 +62,11 @@ const ConnectionResponse = Schema.Struct({ // Last persisted health-check verdict (written by every checkHealth run), // so the list can show alive/expired at a glance without probing. lastHealth: Schema.NullOr(HealthCheckResult), + // True when the connection was minted through MCP Enterprise-Managed + // Authorization: it mirrors organization policy and holds no durable local + // grant, so a console must not offer to delete it. Read-only — nothing on + // this surface can set it. + enterpriseManaged: Schema.Boolean, }); const ToolResponse = Schema.Struct({ diff --git a/packages/core/api/src/handlers/connections.ts b/packages/core/api/src/handlers/connections.ts index 9ae476a97f..de766b07e9 100644 --- a/packages/core/api/src/handlers/connections.ts +++ b/packages/core/api/src/handlers/connections.ts @@ -30,6 +30,7 @@ const toResponse = (c: Connection) => ({ oauthScope: c.oauthScope ?? null, missingOAuthScopes: c.missingOAuthScopes ?? [], lastHealth: c.lastHealth ?? null, + enterpriseManaged: c.enterpriseManaged ?? false, }); const toolToResponse = (t: Tool) => ({ diff --git a/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index 92c3e5ed72..898fafd07b 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -46,6 +46,7 @@ const connectionToResponse = (c: Connection) => ({ oauthClientOwner: c.oauthClientOwner ?? null, oauthScope: c.oauthScope ?? null, missingOAuthScopes: c.missingOAuthScopes ?? [], + enterpriseManaged: c.enterpriseManaged ?? false, }); const startResultToResponse = (result: ConnectResult) => diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index 96e76a26c1..a68d66d3d4 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -52,6 +52,12 @@ const ConnectionResponse = Schema.Struct({ oauthClientOwner: Schema.NullOr(Owner), oauthScope: Schema.NullOr(Schema.String), missingOAuthScopes: Schema.Array(Schema.String), + // True when this connect took the enterprise-managed branch (the ID-JAG + // chain) rather than the interactive one. A `start` against an `id_jag` + // client can still land here as `false` — the profile falls back when the + // server does not advertise it — so the caller reads the outcome, not its + // own intent. + enterpriseManaged: Schema.Boolean, }); // --------------------------------------------------------------------------- diff --git a/packages/core/sdk/src/connection.ts b/packages/core/sdk/src/connection.ts index 9009011774..d84ab17645 100644 --- a/packages/core/sdk/src/connection.ts +++ b/packages/core/sdk/src/connection.ts @@ -61,6 +61,21 @@ export interface Connection { * "has this expired?" at a glance in the connections list without probing. * Null/absent = never checked. */ readonly lastHealth?: HealthCheckResult | null; + /** True when this connection was minted through MCP Enterprise-Managed + * Authorization and renews itself from the enterprise identity assertion + * persisted alongside it (`ENTERPRISE_MANAGED_PROVIDER_STATE_KEY`). + * + * A BOOLEAN, deliberately: the wiring behind it (which IdP registration, + * which audience) is a server concern, and a console needs exactly one fact + * from it — that this connection mirrors organization policy rather than a + * grant the user holds. That single fact changes what the UI may offer: + * there is no durable local grant to delete, so a local "remove" would be a + * lie, and revocation belongs at the identity provider. + * + * It is NOT derivable client-side from `oauthClient`: a client whose grant + * is `id_jag` still falls back to the interactive flow against a server that + * does not advertise the profile, and such a connection is ordinary. */ + readonly enterpriseManaged?: boolean; } /** Identify one connection — unique by (owner, integration, name). */ diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index f1b9443477..a4a4717bd3 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -818,6 +818,22 @@ const missingOAuthScopesFromProviderState = (value: unknown): readonly string[] : []; }; +/** Project a credential-resolution failure's ADMINISTRATOR verdict onto the + * health result, so the console reads "your organization declined this" as + * structure rather than parsing the sentence in `detail`. Empty for every + * ordinary failure — an expired grant, a dead refresh token — which keeps the + * blocked branch impossible to reach by accident. */ +export const healthAdministratorVerdict = (failure: { + readonly blockedByAdmin?: boolean; + readonly oauthErrorCode?: string; +}): { readonly blockedByAdmin?: true; readonly oauthErrorCode?: string } => + failure.blockedByAdmin === true + ? { + blockedByAdmin: true, + ...(failure.oauthErrorCode === undefined ? {} : { oauthErrorCode: failure.oauthErrorCode }), + } + : {}; + /** The definitive refresh rejection recorded on `provider_state`, or null. * Set when the AS rejects the grant itself (RFC 6749 invalid_grant — retrying * cannot change the verdict); cleared by the reconnect mint, which rewrites @@ -852,6 +868,10 @@ const rowToConnection = (row: ConnectionRow): Connection => { oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope), missingOAuthScopes: missingOAuthScopesFromProviderState(row.provider_state), lastHealth: Option.getOrNull(decodeLastHealth(row.last_health)), + // Read from the SAME persisted state the renewal path follows, so the + // console and the credential lifecycle can never disagree about which + // connections are enterprise-managed. + enterpriseManaged: enterpriseManagedStateFrom(decodeJsonColumn(row.provider_state)) !== null, }; }; @@ -3455,6 +3475,7 @@ export const createExecutor = (null); + // Whether this server is declared enterprise-managed, and by which + // registration. Read off the stored oauth2 method rather than kept as a + // parallel flag: the declaration IS the state, and the toggle below only + // decides whether the organization's registration is attached to it. + const declaredProvider = useMemo(() => { + for (const method of server.config.authenticationTemplate) { + if (method.kind === "oauth2" && method.enterpriseIdentityProvider !== undefined) { + return method.enterpriseIdentityProvider; + } + } + return undefined; + }, [server.config.authenticationTemplate]); + + const organizationProvider = useEnterpriseIdentityProviderDescriptor() ?? undefined; + const [managed, setManaged] = useState(declaredProvider !== undefined); + // The declaration this save would write: the organization's registration + // while the toggle is on, and — when the organization has since removed its + // registration — whatever the server already names, so an unrelated edit + // cannot quietly un-manage a live server. + const nextProvider: McpEnterpriseIdentityProvider | undefined = managed + ? (organizationProvider ?? declaredProvider) + : undefined; + // The edited methods, slugs preserved for seeded rows so existing // connections (bound by template slug) stay attached. New rows omit the - // slug — the backend assigns kind-based ones. + // slug — the backend assigns kind-based ones. Each row is reconciled against + // the method it was seeded from, because `configureMcpAuth` runs in `replace` + // mode here: anything the credential editor cannot express — the + // enterprise-managed declaration above all — has to be carried forward + // deliberately or it is erased by an unrelated edit. const editedMethods = useMemo( () => list.rows.map((row: AuthMethodRow): McpCanonicalAuthMethodInput => { - const input = mcpAuthMethodInputFromEditorValue(row.value); - return row.seedSlug !== undefined ? { ...input, slug: row.seedSlug } : input; + const edited = mcpAuthMethodInputFromEditorValue(row.value); + const declared = edited.kind === "oauth2" ? mcpOAuthMethodInput(nextProvider) : edited; + return row.seedSlug !== undefined ? { ...declared, slug: row.seedSlug } : declared; }), - [list.rows], + [list.rows, nextProvider], ); const methodsChanged = useMemo(() => { @@ -118,10 +152,25 @@ function RemoteEdit(props: { if (method.kind === "apikey" && current.kind === "apikey") { return !samePlacements(method.placements, current.placements); } + if (method.kind === "oauth2" && current.kind === "oauth2") { + return !sameEnterpriseIdentityProvider( + method.enterpriseIdentityProvider, + current.enterpriseIdentityProvider, + ); + } return false; }); }, [editedMethods, server.config.authenticationTemplate]); + const hasOAuthMethod = server.config.authenticationTemplate.some( + (method: McpAuthMethod) => method.kind === "oauth2", + ); + // Offered only where it can mean something: this server authenticates with + // OAuth, and either the organization has registered an identity provider or + // this server is already managed by one (so it can still be turned off). + const canDeclareManaged = + hasOAuthMethod && (organizationProvider !== undefined || declaredProvider !== undefined); + // Staged apply, run by the sheet's Save when the method list changed. const applyStaged = useCallback(async (): Promise => { setError(null); @@ -168,6 +217,38 @@ function RemoteEdit(props: { footerHint="Connections pick one of these methods. Removing a method detaches connections created against it." /> + {/* Enterprise-Managed Authorization, per server. The organization's + identity provider is registered once (Organization settings); this is + where an administrator says WHICH servers authorize through it. + Declaring it can never take an ordinary server off the interactive + flow — the connect path still requires the server to advertise the + grant profile — so the copy promises a route, not an outcome. */} + {canDeclareManaged ? ( +
+ +
+ +

+ Members connect with their work identity instead of consenting to this server. Your + identity provider decides who gets access, and revokes it. +

+ {organizationProvider === undefined ? ( +

+ Your organization no longer has an identity provider registered. This server keeps + the one it already names until you turn this off. +

+ ) : null} +
+
+ ) : null} + {error && } ); diff --git a/packages/plugins/mcp/src/react/McpSignInButton.tsx b/packages/plugins/mcp/src/react/McpSignInButton.tsx index 13c2252afc..ba94d670a7 100644 --- a/packages/plugins/mcp/src/react/McpSignInButton.tsx +++ b/packages/plugins/mcp/src/react/McpSignInButton.tsx @@ -2,18 +2,14 @@ import { useMemo, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { - AuthTemplateSlug, - IntegrationSlug, - type Connection, - type Owner, -} from "@executor-js/sdk/shared"; +import { IntegrationSlug, type Connection, type Owner } from "@executor-js/sdk/shared"; import { connectionsAllAtom } from "@executor-js/react/api/atoms"; import { AddAccountModal } from "@executor-js/react/components/add-account-modal"; import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; import type { AuthMethod } from "@executor-js/react/lib/auth-placements"; import { mcpServerAtom } from "./atoms"; +import { authMethodsFromConfig } from "./auth-method-config"; import type { McpAuthMethod } from "../sdk/types"; // --------------------------------------------------------------------------- @@ -47,21 +43,15 @@ export default function McpSignInButton(props: { integrationId: string; owner?: (connection: Connection) => connection.integration === slug, ); + // Projected through the plugin's ONE codec rather than assembled here: the + // server's enterprise-managed declaration rides on this method, and a + // hand-built copy silently dropped it — leaving this button offering + // per-server consent for a server the organization manages. const methods = useMemo( () => remote === null || oauthMethod === null ? [] - : [ - { - id: oauthMethod.slug, - label: "OAuth", - kind: "oauth", - source: "spec", - template: AuthTemplateSlug.make(oauthMethod.slug), - placements: [], - oauth: { discoveryUrl: remote.endpoint, supportsDynamicRegistration: true }, - }, - ], + : authMethodsFromConfig([oauthMethod], remote.endpoint), [remote, oauthMethod], ); const initialState = useMemo( diff --git a/packages/plugins/mcp/src/react/auth-method-config.test.ts b/packages/plugins/mcp/src/react/auth-method-config.test.ts index b6a8556e5f..39b4d78e2e 100644 --- a/packages/plugins/mcp/src/react/auth-method-config.test.ts +++ b/packages/plugins/mcp/src/react/auth-method-config.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { OAuthClientSlug } from "@executor-js/sdk/shared"; import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; import { @@ -6,6 +7,8 @@ import { editorValueFromMcpAuthMethod, mcpAuthMethodInputFromEditorValue, mcpAuthMethodInputsFromPlacements, + mcpOAuthMethodInput, + sameEnterpriseIdentityProvider, } from "./auth-method-config"; describe("mcpAuthMethodInputFromEditorValue", () => { @@ -175,6 +178,112 @@ describe("authMethodsFromConfig", () => { }); }); +// --------------------------------------------------------------------------- +// Enterprise-Managed Authorization: the per-server declaration. +// +// The declaration is a POINTER at the organization's identity-provider +// registration, and it is what puts a server on the work-identity route +// instead of per-server consent. Two things must hold or the feature silently +// stops working: it has to reach the console (a dropped pointer means the +// connect path offers ordinary consent for a managed server), and it must not +// be invented by any surface that cannot see the organization's registration. +// --------------------------------------------------------------------------- + +const PROVIDER = { + client: OAuthClientSlug.make("enterprise-identity-provider"), + clientOwner: "org", +} as const; + +describe("authMethodsFromConfig · enterprise-managed declaration", () => { + it("carries the server's identity-provider pointer onto the rendered method", () => { + const methods = authMethodsFromConfig( + [{ slug: "oauth2", kind: "oauth2", enterpriseIdentityProvider: PROVIDER }], + "https://mcp.example.com/mcp", + ); + expect(methods[0]?.oauth?.enterpriseIdentityProvider).toEqual(PROVIDER); + }); + + it("leaves the interactive route advertised beside it", () => { + // Declaring a provider asks the connect path to TRY the enterprise branch. + // Whether it is taken still depends on the server advertising the grant + // profile, so the ordinary route must remain available. + const methods = authMethodsFromConfig( + [{ slug: "oauth2", kind: "oauth2", enterpriseIdentityProvider: PROVIDER }], + "https://mcp.example.com/mcp", + ); + expect(methods[0]?.oauth?.supportsDynamicRegistration).toBe(true); + expect(methods[0]?.oauth?.discoveryUrl).toBe("https://mcp.example.com/mcp"); + }); + + it("declares nothing for an ordinary oauth2 server", () => { + const methods = authMethodsFromConfig( + [{ slug: "oauth2", kind: "oauth2" }], + "https://mcp.example.com/mcp", + ); + expect(methods[0]?.oauth?.enterpriseIdentityProvider).toBeUndefined(); + }); +}); + +describe("mcpOAuthMethodInput", () => { + it("attaches the organization's registration when the server is managed", () => { + expect(mcpOAuthMethodInput(PROVIDER)).toEqual({ + kind: "oauth2", + enterpriseIdentityProvider: PROVIDER, + }); + }); + + it("omits the key entirely when it is not — never an explicit undefined", () => { + // `configureMcpAuth` decodes this against a union; an explicit + // `enterpriseIdentityProvider: undefined` is a different wire value and is + // rejected, so absence has to be real absence. + const input = mcpOAuthMethodInput(undefined); + expect(input).toEqual({ kind: "oauth2" }); + expect(Object.hasOwn(input, "enterpriseIdentityProvider")).toBe(false); + }); +}); + +describe("mcpAuthMethodInputFromEditorValue · enterprise-managed declaration", () => { + it("invents no declaration from the credential editor", () => { + // The editor edits credentials. Server policy is not one, and a surface + // that cannot see the organization's registration must not guess at it — + // the save path re-attaches it deliberately instead. + expect( + mcpAuthMethodInputFromEditorValue({ + kind: "oauth", + authorizationUrl: "", + tokenUrl: "", + scopes: [], + }), + ).toEqual({ kind: "oauth2" }); + }); +}); + +describe("sameEnterpriseIdentityProvider", () => { + it("compares the pointer by value, not by reference", () => { + // The stored copy is decoded from JSON, so it is never the same object as + // the one the console just built; a reference check would report every + // save as a change. + expect(sameEnterpriseIdentityProvider({ ...PROVIDER }, { ...PROVIDER })).toBe(true); + }); + + it("distinguishes a different registration", () => { + expect(sameEnterpriseIdentityProvider(PROVIDER, { ...PROVIDER, clientOwner: "user" })).toBe( + false, + ); + expect( + sameEnterpriseIdentityProvider(PROVIDER, { + ...PROVIDER, + client: OAuthClientSlug.make("other"), + }), + ).toBe(false); + }); + + it("treats declaring and not declaring as different", () => { + expect(sameEnterpriseIdentityProvider(PROVIDER, undefined)).toBe(false); + expect(sameEnterpriseIdentityProvider(undefined, undefined)).toBe(true); + }); +}); + describe("mcpAuthMethodInputsFromPlacements", () => { it("builds ONE method carrying every named placement", () => { expect( diff --git a/packages/plugins/mcp/src/react/auth-method-config.ts b/packages/plugins/mcp/src/react/auth-method-config.ts index ae09ba49f6..8bb5a6aea9 100644 --- a/packages/plugins/mcp/src/react/auth-method-config.ts +++ b/packages/plugins/mcp/src/react/auth-method-config.ts @@ -21,6 +21,8 @@ import type { McpAuthMethod, McpAuthMethodInput, McpCanonicalAuthMethodInput, + McpEnterpriseIdentityProvider, + McpOAuthMethod, McpStdioEnvMethod, } from "../sdk/types"; @@ -48,20 +50,37 @@ export const mcpWireAuthInput = ( method: McpAuthMethod | McpCanonicalAuthMethodInput, ): McpAuthMethodInput => wireAuthInputFromShared(method) as McpAuthMethodInput; -const oauthAuthMethod = (slug: string, endpoint: string): AuthMethod => ({ - id: slug, +const oauthAuthMethod = (method: McpOAuthMethod, endpoint: string): AuthMethod => ({ + id: method.slug, label: "OAuth", kind: "oauth", - source: slug.startsWith("custom_") ? "custom" : "spec", - template: AuthTemplateSlug.make(slug), + source: method.slug.startsWith("custom_") ? "custom" : "spec", + template: AuthTemplateSlug.make(method.slug), placements: [], - oauth: { discoveryUrl: endpoint, supportsDynamicRegistration: true }, + oauth: { + discoveryUrl: endpoint, + supportsDynamicRegistration: true, + // The server's own enterprise-managed declaration, carried onto the + // presentational method. `supportsDynamicRegistration` stays true beside + // it on purpose: declaring an identity provider is an ADDITIONAL route, not + // a replacement, and a server that turns out not to advertise the ID-JAG + // profile must still be connectable the ordinary way. + ...(method.enterpriseIdentityProvider === undefined + ? {} + : { enterpriseIdentityProvider: method.enterpriseIdentityProvider }), + }, }); /** Convert a generic editor value into one MCP auth-method input (no slug — * the backend assigns carrier-derived slugs). An apikey value keeps every * named placement (headers and query params mix freely); one with no usable - * placement falls back to `none`. */ + * placement falls back to `none`. + * + * Deliberately carries NO enterprise-managed declaration: that is server + * policy, not a credential, so it has no editor field to come from. The + * surface that saves a managed server (`EditMcpIntegration`) re-attaches it + * explicitly from its own toggle, which is what keeps `configureMcpAuth`'s + * `replace` mode from erasing it on an unrelated edit. */ export function mcpAuthMethodInputFromEditorValue( value: AuthTemplateEditorValue, ): McpCanonicalAuthMethodInput { @@ -71,6 +90,31 @@ export function mcpAuthMethodInputFromEditorValue( }) as McpCanonicalAuthMethodInput; } +/** One oauth2 method input carrying the organization's declaration, or none. + * + * The single place an enterprise-managed pointer is attached to a method on + * the way out. Written as a constructor rather than a spread at each call site + * so the absent case stays genuinely ABSENT: an explicit + * `enterpriseIdentityProvider: undefined` is a different value on the wire, + * and the union that decodes it rejects it. */ +export const mcpOAuthMethodInput = ( + provider: McpEnterpriseIdentityProvider | undefined, +): McpCanonicalAuthMethodInput => + provider === undefined + ? { kind: "oauth2" } + : { kind: "oauth2", enterpriseIdentityProvider: provider }; + +/** Whether two enterprise-managed declarations name the same registration. + * Compared field-by-field because the pointer travels through JSON and a + * decoded copy is never reference-equal to the stored one. */ +export const sameEnterpriseIdentityProvider = ( + a: McpEnterpriseIdentityProvider | undefined, + b: McpEnterpriseIdentityProvider | undefined, +): boolean => + a === undefined || b === undefined + ? a === b + : String(a.client) === String(b.client) && a.clientOwner === b.clientOwner; + /** Convert one stored MCP method into the generic editor value. */ export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplateEditorValue { if (method.kind === "oauth2") { @@ -89,7 +133,7 @@ export function authMethodsFromConfig( endpoint: string, ): AuthMethod[] { return methods.map((method: McpAuthMethod): AuthMethod => { - if (method.kind === "oauth2") return oauthAuthMethod(method.slug, endpoint); + if (method.kind === "oauth2") return oauthAuthMethod(method, endpoint); if (method.kind === "stdio_env") return stdioEnvAuthMethod(method); return authMethodFromSharedTemplate(method); }); diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index c20018bd9d..ba496f5322 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -400,6 +400,9 @@ export const addConnectionOptimistic = Atom.family((owner: Owner) => oauthScope: null, missingOAuthScopes: [], lastHealth: null, + // A pasted credential is never enterprise-managed: that state is + // only ever written by the ID-JAG connect path. + enterpriseManaged: false, }; return [optimistic, ...rows]; }), diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index f0ec361dab..ff576cc687 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -15,6 +15,12 @@ import { } from "../api/atoms"; import { connectionWriteKeys } from "../api/reactivity-keys"; import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display"; +import { + MANAGED_CONNECTION_BADGE, + MANAGED_CONNECTION_BLOCKED_LABEL, + MANAGED_CONNECTION_REVOCATION_HINT, + connectionRowPolicy, +} from "../lib/managed-connection"; import { useConnectionHealth } from "../lib/use-connection-health"; import { messageFromExit } from "../api/error-reporting"; import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; @@ -114,8 +120,19 @@ function AccountRow(props: { : null) ?? (probe?.identity && probe.identity.length > 0 ? probe.identity : null); const displayLabel = identity ?? String(connection.name); + // What this row may OFFER, decided from the connection's persisted + // enterprise-managed state and the structured administrator verdict on its + // freshest health result — never from a status word or a message. + const policy = connectionRowPolicy(connection, probe); + const expired = status === "expired"; + // An administrator decision outranks the health word. "Expired" would invite + // a reconnect that cannot succeed, because the route it re-runs is exactly + // the one the identity provider just closed. const needsHealthAttention = status === "expired" || status === "degraded"; + const statusLabel = policy.blockedByAdmin + ? MANAGED_CONNECTION_BLOCKED_LABEL + : HEALTH_STATUS_LABEL[status]; const healthDetail = needsHealthAttention ? probe?.detail : undefined; const missingOAuthScopes = connection.missingOAuthScopes ?? []; @@ -152,9 +169,26 @@ function AccountRow(props: { className={`size-2 shrink-0 rounded-full ${indicator.dot}`} /> {displayLabel} + {policy.managed ? ( + // Grayscale, a word, no hue — this is a fact about the connection, + // not a fault (design.md, "Status and semantics"). It reads + // distinctly from the personal rows beside it, which is the point: + // an additional personal account on the same integration stays + // possible and must stay visibly different. + + {MANAGED_CONNECTION_BADGE} + + ) : null} {needsHealthAttention ? ( - - {HEALTH_STATUS_LABEL[status]} + + {statusLabel} ) : null} {needsReconsent ? ( @@ -183,6 +217,16 @@ function AccountRow(props: { Missing scopes: {missingOAuthScopes.join(", ")} ) : null} + {policy.managed ? ( + + {MANAGED_CONNECTION_REVOCATION_HINT} + + ) : null} + {policy.oauthErrorCode === null ? null : ( + + Reference: {policy.oauthErrorCode} + + )} {props.showOwnerLabel ? ( @@ -213,12 +257,22 @@ function AccountRow(props: { Edit - - Reconnect - - - Remove - + {/* Reconnect and Remove are WITHHELD, not disabled, for an + enterprise-managed connection. Reconnect re-runs the interactive + consent this profile has no step for, and Remove would claim the + member revoked access they cannot revoke here — the identity + provider still authorizes them, and the next call would hand it + straight back. Both live at the provider. */} + {policy.canReconnect ? ( + + Reconnect + + ) : null} + {policy.canRemove ? ( + + Remove + + ) : null} diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index c2a7a5ac98..9d5fa0da09 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -44,6 +44,7 @@ import { oauthClientWriteKeys, } from "../api/reactivity-keys"; import { HEALTH_INDICATOR_COLOR, HEALTH_STATUS_LABEL } from "../lib/health-display"; +import { AdminBlockNotice } from "./admin-block-notice"; import { FreeformCombobox, type FreeformComboboxOption } from "./combobox"; import { messageFromExit } from "../api/error-reporting"; import { trackEvent } from "../api/analytics"; @@ -1618,6 +1619,10 @@ function AddAccountModalView(props: AddAccountModalProps) { const isBuiltInGoogleClient = chosenClient?.origin.kind === "first_party" && String(chosenClient.slug) === "first-party:google"; + // The server names an enterprise identity provider (MCP Enterprise-Managed + // Authorization). Read STRUCTURALLY off the declared method — never from the + // grant of a picked app, which an ordinary connection can also carry. + const managedByOrganization = isOAuth && method?.oauth?.enterpriseIdentityProvider !== undefined; const oauthBusy = ccBusy || oauthPopup.busy; const cimdConnecting = cimdBusy || oauthPopup.busy; const dcrConnecting = dcrBusy || oauthPopup.busy; @@ -2952,11 +2957,33 @@ function AddAccountModalView(props: AddAccountModalProps) { {continueError}

) : null} + {/* The server is declared enterprise-managed. Copy only, and + deliberately: whether this connect actually takes the + enterprise branch is decided at discovery — the server has to + advertise the ID-JAG grant profile — so the notice describes the + arrangement rather than promising an outcome the console cannot + guarantee. It is withdrawn while an administrator denial stands, + where the denial is the more specific truth. */} + {managedByOrganization && oauthPopup.adminBlock === null ? ( +

+ Your organization manages this server. Where it supports work identities, you sign + in with yours and your identity provider decides the access — no consent screen, and + nothing to revoke here. +

+ ) : null} {/* Above the footer, not inside the method tab: the automatic (CIMD/DCR) flows render no tab panel at all, and putting the sign-in error in there left a blocked popup with nothing on screen but the button returning to "Connect". */} - {isOAuth && oauthPopup.error ? ( + {/* An enterprise policy denial replaces the ordinary error line — + it is not a transient fault, and the footer below withdraws + every connect action while it stands. */} + {isOAuth && oauthPopup.adminBlock !== null ? ( + + ) : isOAuth && oauthPopup.error ? (

{oauthPopup.error}

@@ -2978,7 +3005,12 @@ function AddAccountModalView(props: AddAccountModalProps) { - registering a BYO app: the form owns its own submit, no footer; - picked BYO OAuth app: Connect with OAuth / Connect (client creds); - credential/no-auth method: Add connection. */} - {cimdActive ? ( + {/* Blocked by administrator: offer NOTHING that reconnects. + Every branch below is a route to the same authorization the + enterprise just refused, and the interactive one would route + the user around the control outright. Close is the only + action left. */} + {isOAuth && oauthPopup.adminBlock !== null ? null : cimdActive ? ( + + + + + + ); +} + +export function EnterpriseIdentityProviderSection() { + const clientsResult = useAtomValue(oauthClientsOptimisticAtom); + const doRemove = useAtomSet(removeOAuthClientOptimistic, { mode: "promiseExit" }); + const [dialogOpen, setDialogOpen] = useState(false); + + const provider = AsyncResult.isSuccess(clientsResult) + ? findEnterpriseIdentityProvider(clientsResult.value) + : null; + + const handleRemove = async () => { + const exit = await doRemove({ + params: { slug: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG }, + payload: { owner: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_OWNER }, + reactivityKeys: oauthClientWriteKeys, + }); + trackEvent("oauth_client_removed", { owner: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_OWNER }); + toast[Exit.isSuccess(exit) ? "success" : "error"]( + Exit.isSuccess(exit) ? "Identity provider removed" : "Failed to remove identity provider", + ); + }; + + return ( +
+
+
+

Enterprise Identity Provider

+

+ Register Executor's application from your own identity provider. MCP servers marked + as managed then connect with each member's work identity, instead of asking them to + consent server by server. +

+
+ {provider === null ? ( + + ) : null} +
+ + {AsyncResult.match(clientsResult, { + onInitial: () =>
, + onFailure: () => ( +
+

Failed to load the identity provider

+
+ ), + onSuccess: () => + provider === null ? ( +

+ No identity provider yet. Register one before marking any MCP server as managed by + your organization. +

+ ) : ( +
+
+
+
+

+ {provider.tokenUrl} +

+ {/* Grayscale by design: status is a word plus tone, never a + hue (see design.md, Status and semantics). */} + Registered +
+

+ {provider.clientId} +

+
+
+ + + + + + setDialogOpen(true)}> + Edit Provider + + void handleRemove()} + > + Remove Provider + + + +
+
+
+

+ On a managed server, members connect with their work identity instead of a consent + screen — and access is revoked at your identity provider, not here. +

+
+
+ ), + })} + + {/* Mounted only while open so the form — including a typed secret — is + created and destroyed with the dialog. */} + {dialogOpen ? ( + + ) : null} +
+ ); +} diff --git a/packages/react/src/components/oauth-client-form.test.ts b/packages/react/src/components/oauth-client-form.test.ts index f85367ad54..e20a7e2dde 100644 --- a/packages/react/src/components/oauth-client-form.test.ts +++ b/packages/react/src/components/oauth-client-form.test.ts @@ -112,6 +112,60 @@ describe("canSubmitOAuthClientForm", () => { }), ).toBe(false); }); + + // MCP Enterprise-Managed Authorization (`id_jag`). The app registered here is + // the client's registration at the MCP server's Resource Authorization + // Server; discovery starts from the resource identifier, so that field — not + // the authorization URL — is what the grant cannot do without. + it("requires a resource URL for enterprise identity assertion clients", () => { + expect( + canSubmitOAuthClientForm({ + ...validBase, + grant: "id_jag", + authorizationUrl: "", + resource: null, + }), + ).toBe(false); + }); + + it("accepts an enterprise identity assertion client with no authorization URL", () => { + // There is no browser redirect in this profile: the client presents an + // identity assertion instead of walking the user through consent. + expect( + canSubmitOAuthClientForm({ + ...validBase, + grant: "id_jag", + authorizationUrl: "", + resource: "https://mcp.example.com/mcp", + }), + ).toBe(true); + }); + + it("accepts a public enterprise identity assertion client with no secret", () => { + // The ID-JAG itself is the proof of authorization at the Resource + // Authorization Server (draft §4.4), so a secret is not mandatory. + expect( + canSubmitOAuthClientForm({ + ...validBase, + grant: "id_jag", + clientSecret: "", + authorizationUrl: "", + resource: "https://mcp.example.com/mcp", + }), + ).toBe(true); + }); + + it("still requires a token URL for enterprise identity assertion clients", () => { + expect( + canSubmitOAuthClientForm({ + ...validBase, + grant: "id_jag", + authorizationUrl: "", + tokenUrl: "", + resource: "https://mcp.example.com/mcp", + }), + ).toBe(false); + }); }); describe("oauthAppSetupFor", () => { diff --git a/packages/react/src/components/oauth-client-form.tsx b/packages/react/src/components/oauth-client-form.tsx index 9d73c881d8..4990f29fb6 100644 --- a/packages/react/src/components/oauth-client-form.tsx +++ b/packages/react/src/components/oauth-client-form.tsx @@ -99,13 +99,25 @@ export const canSubmitOAuthClientForm = (input: { readonly clientSecret: string; readonly authorizationUrl: string; readonly tokenUrl: string; + /** RFC 9728 resource identifier of the protected resource. Required for the + * `id_jag` grant — it is what enterprise-managed discovery starts from — and + * ignored for the other two, where it is a discovery by-product. */ + readonly resource?: string | null; }): boolean => !input.submitting && input.name.trim().length > 0 && input.clientId.trim().length > 0 && - (input.grant === "authorization_code" || input.clientSecret.trim().length > 0) && + // Only client credentials has no other way to authenticate. The + // authorization-code grant may be a public PKCE client, and an `id_jag` + // client may be public at its Resource Authorization Server too — the ID-JAG + // itself is the proof of authorization there (draft §4.4). + (input.grant !== "client_credentials" || input.clientSecret.trim().length > 0) && input.tokenUrl.trim().length > 0 && - (input.grant === "client_credentials" || input.authorizationUrl.trim().length > 0); + // No browser redirect exists for client credentials, and an enterprise- + // managed client never runs one: it presents an assertion instead of walking + // the user through consent. + (input.grant !== "authorization_code" || input.authorizationUrl.trim().length > 0) && + (input.grant !== "id_jag" || (input.resource ?? "").trim().length > 0); export function OAuthClientForm(props: { /** Human label for the integration this app backs (used in toasts + default name). */ @@ -217,6 +229,10 @@ export function OAuthClientForm(props: { // client id/secret + owner. const endpointsKnown = (prefill?.tokenUrl ?? "").length > 0; const [showEndpoints, setShowEndpoints] = useState(!endpointsKnown); + // The enterprise-managed grant needs a resource identifier that no prefill + // carries (discovery starts from it), so its endpoint panel never collapses — + // a required field must not hide behind an "Edit". + const endpointsCollapsible = endpointsKnown && grant !== "id_jag"; const doCreate = useAtomSet(createOAuthClientOptimistic, { mode: "promiseExit" }); const doProbe = useAtomSet(probeOAuth, { mode: "promiseExit" }); @@ -232,6 +248,7 @@ export function OAuthClientForm(props: { clientSecret, authorizationUrl, tokenUrl, + resource, }); // DCR is offered when the server advertises a registration endpoint AND we @@ -454,6 +471,16 @@ export function OAuthClientForm(props: { label: "Client credentials", hint: "App-to-app, no user", }, + { + // MCP Enterprise-Managed Authorization. The app registered here + // is the client's registration at the MCP server's Resource + // Authorization Server; the second registration (at the + // enterprise identity provider) is the organization's, and the + // server points at it separately. + value: "id_jag", + label: "Enterprise identity assertion", + hint: "Your identity provider authorizes, no per-server consent", + }, ] as const ).map((option) => (