Skip to content

MCP enterprise-managed authorization (client) - #1730

Merged
RhysSullivan merged 11 commits into
mainfrom
ema-client-auth
Aug 26, 2026
Merged

MCP enterprise-managed authorization (client)#1730
RhysSullivan merged 11 commits into
mainfrom
ema-client-auth

Conversation

@RhysSullivan

Copy link
Copy Markdown
Collaborator

Adds client-side support for the MCP Enterprise-Managed Authorization extension, so a user already signed in to their enterprise IdP connects an MCP server with no per-server consent step.

The flow, per the EMA profile of draft-ietf-oauth-identity-assertion-authz-grant: the identity assertion from SSO is exchanged at the IdP for an ID-JAG (RFC 8693), which is redeemed at the MCP server's authorization server for an audience-restricted access token (RFC 7523). Servers opt in by advertising the id-jag grant profile in their RFC 8414 metadata; executor only takes this path when an enterprise IdP is configured for the server AND discovery confirms the profile.

Design decisions

  • A policy denial never falls back to interactive OAuth. Only the grant-profile-unsupported failure is caught at the connect boundary, so a denial cannot silently degrade into the per-server flow, which would let a user consent personally and route around the control the organization just exercised. It surfaces as blocked-by-admin, carrying the IdP's own OAuth error code so the decision is traceable.
  • The verdict is structured, not prose. OAuthStartError gains blockedByAdmin and oauthErrorCode, and CredentialResolutionError carries the same verdict on the refresh path, so a console can branch on the state rather than matching a message.
  • Renewal is non-interactive. This grant issues no refresh token by design; the credential-refresh path re-runs the exchange chain on skew or a 401, and a policy withdrawn after connect is reported as blocked-by-admin rather than as re-auth.

Testing

Two tiers. Hermetic conformance tests drive the protocol against fixtures written to be stricter than any real server (assertion typ, signature, exact audience, client binding, expiry; an ID-JAG offered as a bearer token is rejected), covering the fallback, the three expiry tiers, confused-deputy, and scope narrowing.

The e2e scenario runs real product code against two emulators, Okta as the enterprise IdP and the MCP emulator as resource authorization server and MCP server, and asserts on both request ledgers rather than on executor's own responses. Phase one proves the connection completes with no authorize redirect and the tool call rides the chain-minted token. Phase two seeds an administrator DENY policy and proves the MCP server saw no authorize request, no registration, and no second redemption. Requires emulate 0.14.0.

Follow-ups

  • No UI. The descriptor reaches clients but nothing renders it or collects an identity assertion, so connecting an EMA server is API-only today. Managed connections should be badged as such, and only after discovery confirms the profile.
  • Multi-account is unchanged: EMA binds one enterprise identity, so the connect flow stays for additional accounts on the same server.
  • Removal is deliberately unsupported. A managed connection mirrors policy and holds no durable grant, so a local delete would fight the organization's intent; revisit as a generic per-connection disable if it is ever requested.
  • Connect discovers twice (scope discovery and resource-AS discovery each walk protected-resource metadata to AS metadata); worth collapsing if connect latency matters.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 25, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
executor-cloud a718ae1 Aug 26 2026, 09:58 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 25, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
executor-marketing a718ae1 Commit Preview URL

Branch Preview URL
Aug 26 2026, 09:57 PM

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Cloudflare preview

Torn down — the PR is closed.

@pkg-pr-new

pkg-pr-new Bot commented Aug 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

@executor-js/cli

npm i https://pkg.pr.new/@executor-js/cli@1730

@executor-js/config

npm i https://pkg.pr.new/@executor-js/config@1730

@executor-js/execution

npm i https://pkg.pr.new/@executor-js/execution@1730

@executor-js/sdk

npm i https://pkg.pr.new/@executor-js/sdk@1730

@executor-js/codemode-core

npm i https://pkg.pr.new/@executor-js/codemode-core@1730

@executor-js/runtime-quickjs

npm i https://pkg.pr.new/@executor-js/runtime-quickjs@1730

@executor-js/plugin-file-secrets

npm i https://pkg.pr.new/@executor-js/plugin-file-secrets@1730

@executor-js/plugin-graphql

npm i https://pkg.pr.new/@executor-js/plugin-graphql@1730

@executor-js/plugin-keychain

npm i https://pkg.pr.new/@executor-js/plugin-keychain@1730

@executor-js/plugin-mcp

npm i https://pkg.pr.new/@executor-js/plugin-mcp@1730

@executor-js/plugin-onepassword

npm i https://pkg.pr.new/@executor-js/plugin-onepassword@1730

@executor-js/plugin-openapi

npm i https://pkg.pr.new/@executor-js/plugin-openapi@1730

executor

npm i https://pkg.pr.new/executor@1730

commit: a718ae1

…to connect

An MCP oauth2 method may now name the registered OAuth app that plays its
enterprise identity provider. The catalog projects that pointer so a client
knows which app to name on oauth.start, and the start handler forwards the
enterprise inputs it was already accepting. Declaring a provider only asks the
connect path to try the ID-JAG grant; the server still has to advertise the
profile, so an ordinary server keeps the interactive flow.
The refresh path switched on _tag by hand and the fallback rule lived in an
exported predicate nothing called. Use catchTags for the mapping, assert the
tags directly in the tests, and drop the predicate: the connect path's single
catchTag is where that rule is actually enforced.
…tors

A selfhost scenario runs the whole ID-JAG chain through the product: Okta
issues the ID token, executor exchanges it for an ID-JAG and redeems it at the
MCP server, and a tool call rides the result with no consent step. Seeding one
DENY policy then proves a refusal surfaces as blocked-by-admin and does not
fall back to the interactive flow. Both emulator ledgers carry the assertions.
Needs emulate 0.14.0 for the Okta token exchange and its policy table.
The ID-JAG exchange had its own error-body machinery beside the existing
one, and it only understood a conform RFC 6749 envelope. An IdP answering
{"errors":["invalid_grant - ..."]} therefore read as a transport failure
and was retried forever instead of asking for a fresh sign-on.

Route it through toOAuth2ErrorWithHttpSummary, which now tries the conform
envelope first and falls back to the closed-set non-conform recovery. The
recovery only runs on 4xx, so a 5xx carrying an error code stays a
retryable transport verdict rather than becoming a permanent refusal.

Also hoist the body-reading helpers: a body-read failure on the exchange
response now fails distinctly instead of rendering as "did not match RFC
8693 2.2.1", and the read is passed as a thunk so a clone() on a consumed
body is caught rather than escaping as a defect.
A start failure carried only prose, so a console could not tell an
administrator refusal from a credential problem — and the two demand
opposite behaviour: blocked-by-admin must NOT offer the interactive
per-server flow, because that walks the user around the policy the
identity provider just enforced. Add blockedByAdmin and oauthErrorCode
alongside the pattern OAuthCompleteError already sets with
restartRequired.

Same file, second concern: { client, clientOwner } was written three times
(SDK interface, API Schema.Struct, MCP plugin Schema.Struct) and the
enterprise connect input twice. Each is now one Schema in the SDK that the
API and the MCP plugin reference.

SUBJECT_TOKEN_TYPES goes back to module-local: it is only ever an argument
to Schema.Literals.
…efresh

The connect boundary flattened every EMA failure to a message string, so
the taxonomy the module argues for at length stopped at the service edge.
Translate per tag instead: a policy denial reaches the caller as
blockedByAdmin with the identity providers own error code.

Split the error union so minting cannot claim a verdict it never reaches.
mintEnterpriseManagedAccessToken never inspects metadata, so
EmaGrantProfileUnsupported belongs only to the discovery entry point; the
refresh path loses its unreachable arm.

Also: the connect paths authorization-server discovery was unbounded,
because it was copied from scope discovery without the timeout. Both now
share one capped, bounded probe loop. The inert Effect.provide on the EMA
chain is gone — oauth4webapi drives the configured fetch, not HttpClient,
and the layer read as a claim the chain did not honour.
Substring-matching the rendered prose let the tests pass on any error that
happened to say the right thing, and left blockedByAdmin with no coverage
at all. Assert the tag and the fields instead, at the unit, lifecycle, and
e2e tiers.

Cover the case that only exists over time: an administrator withdrawing
access AFTER a connection was made, which the credential-refresh path
meets with no user present. That is the only producer of blockedByAdmin on
a credential failure, and it was untested. The OAuth fixture grows a
policy control for it, since a denial that is fixed at construction cannot
express the change.

Decode the fixtures metadata through the production schema, so a decoder
that stopped retaining authorization_grant_profiles_supported would fail
the gate that reads it.
@RhysSullivan
RhysSullivan marked this pull request as ready for review August 26, 2026 22:03
@RhysSullivan
RhysSullivan merged commit 5d18978 into main Aug 26, 2026
44 checks passed
@RhysSullivan
RhysSullivan deleted the ema-client-auth branch August 26, 2026 22:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant