From 7f0bfa171e7616ae26bfc84f4c776ac97cdca3e8 Mon Sep 17 00:00:00 2001
From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
Date: Wed, 26 Aug 2026 15:51:58 -0700
Subject: [PATCH 1/6] Project enterprise-managed state onto connections and
health results
---
packages/core/api/src/connections/api.ts | 5 +++++
packages/core/api/src/handlers/connections.ts | 1 +
packages/core/api/src/handlers/oauth.ts | 1 +
packages/core/api/src/oauth/api.ts | 6 +++++
packages/core/sdk/src/connection.ts | 15 +++++++++++++
packages/core/sdk/src/executor.ts | 22 +++++++++++++++++++
packages/core/sdk/src/health-check.ts | 13 +++++++++++
packages/react/src/api/atoms.tsx | 3 +++
8 files changed, 66 insertions(+)
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 =
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];
}),
From fdf9c317af87d138742f5d9a32b50adc164d6a8a Mon Sep 17 00:00:00 2001
From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
Date: Wed, 26 Aug 2026 15:53:17 -0700
Subject: [PATCH 2/6] Expose the enterprise identity assertion grant in the
OAuth app form
---
.../src/components/oauth-client-form.test.ts | 54 +++++++++++++++
.../src/components/oauth-client-form.tsx | 66 +++++++++++++++++--
2 files changed, 113 insertions(+), 7 deletions(-)
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) => (
) : 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 ? (
void handleCimdConnect()}
diff --git a/packages/react/src/components/admin-block-notice.tsx b/packages/react/src/components/admin-block-notice.tsx
new file mode 100644
index 0000000000..d496b98f64
--- /dev/null
+++ b/packages/react/src/components/admin-block-notice.tsx
@@ -0,0 +1,40 @@
+import {
+ ADMIN_BLOCK_NEXT_STEP,
+ ADMIN_BLOCK_TITLE,
+ adminBlockReference,
+ type OAuthAdminBlock,
+} from "../plugins/oauth-admin-block";
+
+// ---------------------------------------------------------------------------
+// The blocked-by-administrator notice.
+//
+// Presented as an ORGANIZATION DECISION, not an error the user can work
+// around: no retry, no alternative sign-in, no "try again" — every one of those
+// would offer the route the identity provider just closed. What it does give is
+// the provider's own code, so a member can quote it to whoever administers the
+// policy.
+//
+// Grayscale, per design.md: destructive red is for irreversible actions and
+// faults, and this is neither. It is a policy outcome.
+// ---------------------------------------------------------------------------
+
+export function AdminBlockNotice(props: {
+ readonly block: OAuthAdminBlock;
+ readonly className?: string;
+}) {
+ const reference = adminBlockReference(props.block);
+ return (
+
+
{ADMIN_BLOCK_TITLE}
+
{props.block.message}
+
{ADMIN_BLOCK_NEXT_STEP}
+ {reference === null ? null : (
+
{reference}
+ )}
+
+ );
+}
diff --git a/packages/react/src/lib/managed-connection.test.ts b/packages/react/src/lib/managed-connection.test.ts
new file mode 100644
index 0000000000..d44cdd8bd2
--- /dev/null
+++ b/packages/react/src/lib/managed-connection.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from "@effect/vitest";
+import {
+ AuthTemplateSlug,
+ ConnectionAddress,
+ ConnectionName,
+ IntegrationSlug,
+ ProviderKey,
+ type Connection,
+ type HealthCheckResult,
+} from "@executor-js/sdk/shared";
+
+import { connectionRowPolicy } from "./managed-connection";
+
+const connection = (overrides: Partial = {}): Connection => ({
+ owner: "org",
+ integration: IntegrationSlug.make("linear_mcp"),
+ name: ConnectionName.make("main"),
+ template: AuthTemplateSlug.make("oauth2"),
+ provider: ProviderKey.make("default"),
+ address: ConnectionAddress.make("linear_mcp.org.main"),
+ ...overrides,
+});
+
+const health = (overrides: Partial = {}): HealthCheckResult => ({
+ status: "healthy",
+ checkedAt: 0,
+ ...overrides,
+});
+
+describe("connectionRowPolicy", () => {
+ it("offers Remove and Reconnect on an ordinary connection", () => {
+ const policy = connectionRowPolicy(connection(), health());
+ expect(policy).toMatchObject({ managed: false, canRemove: true, canReconnect: true });
+ });
+
+ it("withholds Remove on an enterprise-managed connection", () => {
+ // There is nothing local to delete: renewal re-runs the ID-JAG chain from
+ // the stored assertion, so a row that vanished would claim a revocation
+ // that did not happen — the provider still authorizes the member.
+ expect(connectionRowPolicy(connection({ enterpriseManaged: true }), health()).canRemove).toBe(
+ false,
+ );
+ });
+
+ it("withholds Reconnect on an enterprise-managed connection", () => {
+ // Reconnect exists to re-consent through the browser. This profile has no
+ // consent step, and the console holds no identity assertion to present.
+ expect(
+ connectionRowPolicy(connection({ enterpriseManaged: true }), health()).canReconnect,
+ ).toBe(false);
+ });
+
+ it("withholds Reconnect from an ORDINARY connection an administrator blocked", () => {
+ // The interactive flow is exactly the route the identity provider just
+ // closed; offering it would route the user around the decision.
+ const policy = connectionRowPolicy(
+ connection(),
+ health({ status: "expired", blockedByAdmin: true }),
+ );
+ expect(policy.canReconnect).toBe(false);
+ // Removing an ordinary connection is still the member's own call.
+ expect(policy.canRemove).toBe(true);
+ });
+
+ it("reads the administrator verdict from the field, never the status word", () => {
+ expect(connectionRowPolicy(connection(), health({ status: "expired" })).blockedByAdmin).toBe(
+ false,
+ );
+ });
+
+ it("surfaces the provider's code only while the block stands", () => {
+ expect(
+ connectionRowPolicy(
+ connection(),
+ health({ blockedByAdmin: true, oauthErrorCode: "invalid_target" }),
+ ).oauthErrorCode,
+ ).toBe("invalid_target");
+ // A code left over from some other refusal is not an administrator
+ // decision, and must not be presented as one.
+ expect(
+ connectionRowPolicy(connection(), health({ oauthErrorCode: "invalid_grant" })).oauthErrorCode,
+ ).toBeNull();
+ });
+
+ it("treats a never-checked connection as unblocked", () => {
+ expect(connectionRowPolicy(connection({ enterpriseManaged: true }), null)).toMatchObject({
+ managed: true,
+ blockedByAdmin: false,
+ oauthErrorCode: null,
+ });
+ });
+
+ it("does not infer managed state from an absent field", () => {
+ // `enterpriseManaged` is projected server-side from the connection's
+ // persisted state. An older row that carries nothing is ordinary, not
+ // ambiguous.
+ expect(connectionRowPolicy(connection(), undefined).managed).toBe(false);
+ });
+});
diff --git a/packages/react/src/lib/managed-connection.ts b/packages/react/src/lib/managed-connection.ts
new file mode 100644
index 0000000000..7f0f2689c1
--- /dev/null
+++ b/packages/react/src/lib/managed-connection.ts
@@ -0,0 +1,78 @@
+import type { Connection, HealthCheckResult } from "@executor-js/sdk/shared";
+
+// ---------------------------------------------------------------------------
+// Enterprise-managed connections — what a row may offer.
+//
+// An enterprise-managed connection mirrors organization policy. It holds no
+// durable grant of its own: renewal re-runs the ID-JAG chain from the identity
+// assertion, and the enterprise identity provider decides every time. Two
+// consequences drive everything below.
+//
+// 1. There is nothing local to delete. A "Remove" that dropped the row would
+// claim the user had revoked access when they had not — the identity
+// provider still authorizes them, and the next connect would hand it
+// straight back. Revocation lives at the provider.
+//
+// 2. There is no interactive route to re-run. Reconnect exists to re-consent
+// through the browser, and this profile has no consent step; the console
+// also holds no identity assertion to present. Offering it would produce
+// a failure the user cannot act on.
+//
+// Connect stays available for ADDITIONAL personal accounts on the same
+// integration: enterprise-managed authorization binds exactly one enterprise
+// identity, and it does not claim the integration.
+// ---------------------------------------------------------------------------
+
+/** Grayscale, a word, no hue — see design.md, "Status and semantics". */
+export const MANAGED_CONNECTION_BADGE = "Managed by your organization";
+
+/** Why the row offers no Remove. Shown as helper text, sentence case. */
+export const MANAGED_CONNECTION_REVOCATION_HINT =
+ "This connection follows your organization's policy. Revoke access at your identity provider, not here.";
+
+/** What an enterprise-managed row shows when the identity provider has since
+ * declined to renew it. The status word for this state — NOT "Expired",
+ * which would invite a reconnect that cannot succeed. */
+export const MANAGED_CONNECTION_BLOCKED_LABEL = "Blocked by your organization";
+
+export interface ConnectionRowPolicy {
+ /** The connection was minted through enterprise-managed authorization. */
+ readonly managed: boolean;
+ /** An administrator decision is the CURRENT state of this connection: the
+ * last credential resolution was refused by the identity provider. */
+ readonly blockedByAdmin: boolean;
+ /** The provider's RFC 6749 §5.2 code, for support traceability. */
+ readonly oauthErrorCode: string | null;
+ /** Whether the row may offer to delete this connection. */
+ readonly canRemove: boolean;
+ /** Whether the row may offer to re-run an interactive OAuth flow. */
+ readonly canReconnect: boolean;
+}
+
+/**
+ * What one connection row may offer, from the connection and its freshest
+ * health verdict.
+ *
+ * Both inputs are read STRUCTURALLY: `enterpriseManaged` is projected from the
+ * connection's persisted provider state, and `blockedByAdmin` is a typed field
+ * on the health result. Neither is inferred from a message, a status word, or
+ * the grant of the OAuth app behind the connection — an `id_jag` app still
+ * falls back to the interactive flow against a server that does not advertise
+ * the profile, and such a connection is ordinary in every way.
+ */
+export const connectionRowPolicy = (
+ connection: Connection,
+ health: HealthCheckResult | null | undefined,
+): ConnectionRowPolicy => {
+ const managed = connection.enterpriseManaged === true;
+ const blockedByAdmin = health?.blockedByAdmin === true;
+ return {
+ managed,
+ blockedByAdmin,
+ oauthErrorCode: blockedByAdmin ? (health?.oauthErrorCode ?? null) : null,
+ canRemove: !managed,
+ // A blocked connection is never reconnectable either, managed or not: the
+ // interactive flow is exactly the route the enterprise just closed.
+ canReconnect: !managed && !blockedByAdmin,
+ };
+};
diff --git a/packages/react/src/plugins/oauth-admin-block.test.ts b/packages/react/src/plugins/oauth-admin-block.test.ts
new file mode 100644
index 0000000000..103aeda12d
--- /dev/null
+++ b/packages/react/src/plugins/oauth-admin-block.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from "@effect/vitest";
+import * as Exit from "effect/Exit";
+import { OAuthStartError } from "@executor-js/sdk/shared";
+
+import { adminBlockFrom, adminBlockFromExit, adminBlockReference } from "./oauth-admin-block";
+
+// The claim these tests defend is a product claim, not a parsing one: a console
+// may only withdraw the interactive route when the identity provider actually
+// refused under policy. Reading it wrong in one direction strands a user with a
+// recoverable error and no retry; in the other, it walks them around the exact
+// control the enterprise just exercised.
+
+const denial = new OAuthStartError({
+ message: "Your organization does not permit this server.",
+ blockedByAdmin: true,
+ oauthErrorCode: "invalid_target",
+});
+
+describe("adminBlockFrom", () => {
+ it("reads the verdict off the typed field", () => {
+ expect(adminBlockFrom(denial)).toEqual({
+ message: "Your organization does not permit this server.",
+ oauthErrorCode: "invalid_target",
+ });
+ });
+
+ it("carries a null reference when the provider returned no code", () => {
+ expect(
+ adminBlockFrom(new OAuthStartError({ message: "Refused.", blockedByAdmin: true }))
+ ?.oauthErrorCode,
+ ).toBeNull();
+ });
+
+ it("leaves an ordinary start failure alone", () => {
+ // No verdict means the interactive route stays open — an expired app, a
+ // bad endpoint, a network fault are all things a retry can fix.
+ expect(
+ adminBlockFrom(new OAuthStartError({ message: "Failed to reach the token endpoint." })),
+ ).toBeNull();
+ });
+
+ it("does not treat an explicitly-false verdict as a denial", () => {
+ expect(
+ adminBlockFrom(new OAuthStartError({ message: "Refused.", blockedByAdmin: false })),
+ ).toBeNull();
+ });
+
+ it("never infers a denial from the wording of a message", () => {
+ // The whole reason the field exists. A message that says the words is
+ // still not a verdict, and a console that matched on text would start
+ // withdrawing the retry for failures the user could have recovered from.
+ expect(
+ adminBlockFrom(
+ new OAuthStartError({ message: "blocked by admin: your organization declined" }),
+ ),
+ ).toBeNull();
+ });
+
+ it("finds the verdict through the popup flow's one level of wrapping", () => {
+ // `openAuthorization` rejects with its own error carrying the server's
+ // failure as `cause`; the verdict has to survive that hop or the modal
+ // sees only a sentence.
+ expect(adminBlockFrom({ message: "Failed to start sign-in", cause: denial })).toEqual({
+ message: "Your organization does not permit this server.",
+ oauthErrorCode: "invalid_target",
+ });
+ });
+
+ it("does not go hunting down a cause chain", () => {
+ // One level is the contract. A chain is not a search space: something
+ // deeper is not this connect's verdict.
+ expect(adminBlockFrom({ cause: { cause: denial } })).toBeNull();
+ });
+
+ it("reads nothing out of values that are not start failures", () => {
+ expect(adminBlockFrom(null)).toBeNull();
+ expect(adminBlockFrom("blocked")).toBeNull();
+ expect(adminBlockFrom({ blockedByAdmin: true })).toBeNull();
+ });
+});
+
+describe("adminBlockFromExit", () => {
+ it("reads the verdict out of a failed exit", () => {
+ expect(adminBlockFromExit(Exit.fail(denial))?.oauthErrorCode).toBe("invalid_target");
+ });
+
+ it("has no verdict for a successful exit", () => {
+ expect(adminBlockFromExit(Exit.succeed({ status: "connected" }))).toBeNull();
+ });
+});
+
+describe("adminBlockReference", () => {
+ it("quotes the provider's own code so a member can trace it", () => {
+ expect(adminBlockReference({ message: "…", oauthErrorCode: "invalid_target" })).toBe(
+ "Reference: invalid_target",
+ );
+ });
+
+ it("shows no reference line when there is no code to show", () => {
+ expect(adminBlockReference({ message: "…", oauthErrorCode: null })).toBeNull();
+ });
+});
diff --git a/packages/react/src/plugins/oauth-admin-block.ts b/packages/react/src/plugins/oauth-admin-block.ts
new file mode 100644
index 0000000000..7be23de320
--- /dev/null
+++ b/packages/react/src/plugins/oauth-admin-block.ts
@@ -0,0 +1,75 @@
+import * as Exit from "effect/Exit";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+import { OAuthStartError } from "@executor-js/sdk/shared";
+
+// ---------------------------------------------------------------------------
+// Blocked by administrator — reading an enterprise policy denial as STRUCTURE.
+//
+// When an enterprise identity provider refuses to mint an ID-JAG, the failure
+// is an administrator's decision, not a credential problem. `OAuthStartError`
+// says so in a typed field (`blockedByAdmin`) precisely so a console never has
+// to read the sentence: matching on message text would silently start passing
+// or failing whenever the wording changed, and the consequence of getting it
+// wrong is offering the interactive per-server flow — which would route the
+// user straight around the control the enterprise just exercised.
+//
+// So: this module decodes the field, and NOTHING here inspects `message`
+// except to display it.
+// ---------------------------------------------------------------------------
+
+/** An enterprise identity provider declined this connect under administrator
+ * policy. Terminal by construction: there is no retry and no alternative
+ * route to offer, only the decision and a code support can trace. */
+export interface OAuthAdminBlock {
+ /** The failure's own message, shown verbatim. */
+ readonly message: string;
+ /** The identity provider's RFC 6749 §5.2 code (`unauthorized_client`,
+ * `invalid_target`, …), when it returned one. Null otherwise. */
+ readonly oauthErrorCode: string | null;
+}
+
+const decodeStartError = Schema.decodeUnknownOption(OAuthStartError);
+
+/** One level of wrapping: the popup flow wraps a start failure in its own
+ * tagged error before it reaches a renderer, and the verdict must survive
+ * that hop. Exactly one level — a `cause` chain is not a search space. */
+const decodeWrapped = Schema.decodeUnknownOption(Schema.Struct({ cause: Schema.Unknown }));
+
+const directAdminBlock = (error: unknown): OAuthAdminBlock | null =>
+ Option.match(decodeStartError(error), {
+ onNone: () => null,
+ onSome: (start) =>
+ start.blockedByAdmin === true
+ ? { message: start.message, oauthErrorCode: start.oauthErrorCode ?? null }
+ : null,
+ });
+
+/** The administrator verdict carried by a failed `oauth.start`, or null when
+ * the failure is anything else — including every other OAuth start failure,
+ * which leaves the interactive route open. */
+export const adminBlockFrom = (error: unknown): OAuthAdminBlock | null =>
+ directAdminBlock(error) ??
+ Option.match(decodeWrapped(error), {
+ onNone: () => null,
+ onSome: (wrapper) => directAdminBlock(wrapper.cause),
+ });
+
+/** `adminBlockFrom` over an `Exit`, for the mutation call sites that hold one. */
+export const adminBlockFromExit = (exit: Exit.Exit): OAuthAdminBlock | null =>
+ Option.match(Exit.findErrorOption(exit), {
+ onNone: () => null,
+ onSome: adminBlockFrom,
+ });
+
+/** What the user is told. Sentence case per the design system's voice rules:
+ * what happened, then what to do next — and the next step is a person, not a
+ * button, because no action in this console can change the verdict. */
+export const ADMIN_BLOCK_TITLE = "Blocked by your organization";
+export const ADMIN_BLOCK_NEXT_STEP =
+ "Ask an administrator to allow this server at your identity provider.";
+
+/** The support-traceable reference line, or null when the provider returned no
+ * code. Mono metadata, per the design system. */
+export const adminBlockReference = (block: OAuthAdminBlock): string | null =>
+ block.oauthErrorCode === null ? null : `Reference: ${block.oauthErrorCode}`;
diff --git a/packages/react/src/plugins/oauth-sign-in.tsx b/packages/react/src/plugins/oauth-sign-in.tsx
index f46d6378e5..5febaeaf95 100644
--- a/packages/react/src/plugins/oauth-sign-in.tsx
+++ b/packages/react/src/plugins/oauth-sign-in.tsx
@@ -16,6 +16,7 @@ import {
} from "../api/oauth-popup";
import { connectionWriteKeys } from "../api/reactivity-keys";
import { getActiveOrgSlug } from "../api/server-connection";
+import { adminBlockFromExit, type OAuthAdminBlock } from "./oauth-admin-block";
export type DesktopBridge = {
readonly openExternal: (url: string) => Promise;
@@ -193,6 +194,10 @@ export function useOAuthPopupFlow<
const blockedMessage = popupBlockedMessage ?? POPUP_BLOCKED_MESSAGE;
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
+ // The enterprise verdict, held apart from `error` on purpose: a renderer must
+ // be able to tell "this failed, try again" from "your organization decided
+ // this" WITHOUT reading either string.
+ const [adminBlock, setAdminBlock] = useState(null);
const cleanupRef = useRef<(() => void) | null>(null);
const sessionRef = useRef<{ readonly state: string } | null>(null);
// A window reserved on the click but not yet handed to a flow owns nothing
@@ -274,6 +279,7 @@ export function useOAuthPopupFlow<
}
setBusy(true);
setError(null);
+ setAdminBlock(null);
// Desktop hosts open the auth URL in the user's real browser, so they
// reserve no in-page window and rely on the polling channel for the
// result.
@@ -294,6 +300,12 @@ export function useOAuthPopupFlow<
);
if (Exit.isFailure(startExit)) {
const message = messageFromExit(startExit, startErrorMessage ?? "Failed to start sign-in");
+ // Read the enterprise verdict off the failure BEFORE it is reduced to a
+ // string. Present means the identity provider refused under
+ // administrator policy, and this flow ends here: the popup closes and
+ // no retry is offered, because retrying is the route the enterprise
+ // just closed.
+ const blocked = adminBlockFromExit(startExit);
reportHandledError(startExit.cause, {
surface: "oauth",
action: "start",
@@ -302,6 +314,7 @@ export function useOAuthPopupFlow<
});
reservedPopup?.popup.close();
setBusy(false);
+ setAdminBlock(blocked);
setError(message);
input.onError?.(message);
return;
@@ -455,29 +468,37 @@ export function useOAuthPopupFlow<
newConnection: input.payload.newConnection,
redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath),
},
- }).then((exit) =>
- Exit.isSuccess(exit)
- ? // The redirect branch carries `authorizationUrl` + `state`; the
- // inline "connected" (client_credentials) branch has no URL to
- // open and no redirect, so `state` is intentionally empty — it
- // is never read for an already-minted connection.
- exit.value.status === "redirect"
+ }).then((exit) => {
+ if (Exit.isSuccess(exit)) {
+ // The redirect branch carries `authorizationUrl` + `state`; the
+ // inline "connected" (client_credentials / enterprise-managed)
+ // branch has no URL to open and no redirect, so `state` is
+ // intentionally empty — it is never read for an already-minted
+ // connection.
+ return exit.value.status === "redirect"
? { state: exit.value.state, authorizationUrl: exit.value.authorizationUrl }
- : { state: "", authorizationUrl: null }
- : Effect.runPromise(
- Effect.fail({
- message: messageFromExit(exit, startErrorMessage ?? "Failed to start sign-in"),
- }),
- ),
- ),
+ : { state: "", authorizationUrl: null };
+ }
+ // Reject with the server's OWN typed failure, cause and all — not
+ // with a message extracted from it. `openAuthorization` wraps this
+ // as the `cause` of its start error, and that is where the
+ // enterprise verdict (`blockedByAdmin`) is read from; flattening it
+ // to a string here would leave the console with only a sentence to
+ // branch on, which is exactly what it must not decide from.
+ return Effect.runPromise(Effect.failCause(exit.cause));
+ }),
});
},
- [callbackPath, doStartOAuth, openAuthorization, startErrorMessage],
+ [callbackPath, doStartOAuth, openAuthorization],
);
return {
busy,
error,
+ /** The enterprise policy denial behind `error`, when there was one. A
+ * renderer branches on this — never on `error`'s wording — and must not
+ * offer a retry while it is set. */
+ adminBlock,
setError,
start,
openAuthorization,
From 89688961b0bd377f0ac412fae6404c61a89f3bbf Mon Sep 17 00:00:00 2001
From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
Date: Wed, 26 Aug 2026 16:28:26 -0700
Subject: [PATCH 6/6] Add a browser scenario for the enterprise-managed console
---
.../mcp-enterprise-managed-console.test.ts | 508 ++++++++++++++++++
1 file changed, 508 insertions(+)
create mode 100644 e2e/selfhost/mcp-enterprise-managed-console.test.ts
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);
+ }),
+ );
+ }),
+ ),
+);