// ---------------------------------------------------------------------------// Enterprise-Managed Authorization (EMA) — the client half.//// MCP "Enterprise-Managed Authorization" profiles// draft-ietf-oauth-identity-assertion-authz-grant-04 for the case where the MCP// client and the MCP server share an enterprise IdP. Instead of walking the// user through per-server consent, the client://// 1. holds an identity assertion from SSO with the IdP,// 2. exchanges it at the IdP for an Identity Assertion JWT Authorization// Grant (ID-JAG) naming the MCP server's authorization server (§4.3), and// 3. redeems that ID-JAG at the Resource Authorization Server for an access// token bound to the MCP server (§4.4).//// The IdP evaluates administrator policy at step 2, which is the whole point of// the profile (§7.2): the enterprise decides which users may reach which// servers with which scopes. That makes the error taxonomy below load-bearing// rather than cosmetic — see `EmaPolicyDenied`.//// This module owns the chain only. Discovery lives in `./oauth-discovery`, the// two token-endpoint round trips in `./oauth-helpers`, and persistence in the// executor's credential lifecycle.// --------------------------------------------------------------------------- import { Data, Effect, Option, Schema } from "effect"; import { IntegrationSlug, OAuthClientSlug, Owner } from "./ids";import { DEFAULT_SUBJECT_TOKEN_TYPE, SubjectTokenTypeSchema, type SubjectTokenType,} from "./oauth-client";import { ID_JAG_GRANT_PROFILE, supportsIdJagGrantProfile, type OAuthAuthorizationServerMetadata,} from "./oauth-discovery";import { exchangeSubjectTokenForIdJag, redeemIdJagAssertion, type OAuth2Error, type OAuth2TokenResponse, type OAuthEndpointUrlPolicy,} from "./oauth-helpers"; // ---------------------------------------------------------------------------// Persisted connection state//// Everything the renewal path needs that is NOT already a column on the// connection or its `oauth_client` row. The `oauth_client` supplies the client's// registration at the Resource Authorization Server (id/secret/token URL/// resource); the connection supplies the granted scope and the identity// assertion (stored in the same credential-provider item a refresh token would// occupy — it plays exactly that role here). What remains is the pointer to the// IdP registration and the discovered `audience`, which cannot be derived from// either.// --------------------------------------------------------------------------- export const EnterpriseManagedConnectionStateSchema = Schema.Struct({ /** `oauth_client` slug of the client's registration at the enterprise IdP. */ idpClient: OAuthClientSlug, idpClientOwner: Owner, /** The Resource Authorization Server's issuer identifier, as discovered from * its RFC 8414 metadata when the connection was made. */ audience: Schema.String, subjectTokenType: SubjectTokenTypeSchema,}).annotate({ identifier: "EnterpriseManagedConnectionState", description: "Enterprise-managed authorization wiring persisted on a connection: which IdP registration mints its ID-JAG, and the audience that ID-JAG must name.",});export type EnterpriseManagedConnectionState = typeof EnterpriseManagedConnectionStateSchema.Type; /** The key `EnterpriseManagedConnectionState` occupies inside a connection's * `provider_state` JSON, alongside the other core-owned keys. */export const ENTERPRISE_MANAGED_PROVIDER_STATE_KEY = "enterpriseManaged"; const decodeEnterpriseManagedProviderState = Schema.decodeUnknownOption( Schema.Struct({ [ENTERPRISE_MANAGED_PROVIDER_STATE_KEY]: EnterpriseManagedConnectionStateSchema, }),); /** Read the enterprise-managed wiring off a connection's decoded * `provider_state`, or null when the connection is not enterprise-managed. * A malformed entry reads as absent — the refresh path then fails loudly with * "not enterprise-managed" rather than half-running the chain on fragments. */export const enterpriseManagedStateFrom = ( providerState: unknown,): EnterpriseManagedConnectionState | null => Option.match(decodeEnterpriseManagedProviderState(providerState), { onNone: () => null, onSome: (decoded) => decoded[ENTERPRISE_MANAGED_PROVIDER_STATE_KEY], }); // ---------------------------------------------------------------------------// Errors//// The distinctions here drive product behavior, so they are separate tags// rather than one error carrying a code://// - `EmaGrantProfileUnsupported` is the ONLY failure that permits falling// back to the ordinary interactive per-server OAuth flow. The server simply// does not implement the profile.// - `EmaPolicyDenied` and `EmaSubjectTokenRejected` MUST NOT fall back.// Falling back would let a user route around the enterprise policy the IdP// just enforced, which is precisely the control this profile exists to// provide.// --------------------------------------------------------------------------- /** The Resource Authorization Server does not advertise the ID-JAG grant * profile in its RFC 8414 metadata (draft §7.2). Enterprise-managed * authorization is not available for this server; the caller MAY fall back to * the ordinary interactive authorization-code flow. */export class EmaGrantProfileUnsupported extends Data.TaggedError("EmaGrantProfileUnsupported")<{ readonly issuer: string; readonly advertised: readonly string[];}> { override get message(): string { return `The authorization server ${this.issuer} does not advertise ${ID_JAG_GRANT_PROFILE}${ this.advertised.length > 0 ? ` (advertised: ${this.advertised.join(", ")})` : "" }.`; }} /** The enterprise IdP refused to mint an ID-JAG for this client, user, resource * or scope set. This is an administrator decision, not a credential problem: * the user cannot fix it by signing in again, and the client MUST NOT offer * the interactive per-server flow as an alternative route. Surface it as * blocked-by-admin and stop. */export class EmaPolicyDenied extends Data.TaggedError("EmaPolicyDenied")<{ /** The IdP's RFC 6749 §5.2 error code (`unauthorized_client`, `access_denied`, * `invalid_target`, `invalid_scope`, `invalid_client`, …). */ readonly error: string; readonly detail: string;}> { override get message(): string { return `Your organization's identity provider did not authorize this MCP server (${this.error}): ${this.detail}`; }} /** The IdP rejected the identity assertion itself (RFC 6749 `invalid_grant`): * expired, revoked, or issued for a different client. The user must sign in * with the enterprise IdP again to obtain a fresh assertion. */export class EmaSubjectTokenRejected extends Data.TaggedError("EmaSubjectTokenRejected")<{ readonly detail: string;}> { override get message(): string { return `The enterprise identity assertion was rejected and a new single sign-on is required: ${this.detail}`; }} /** The Resource Authorization Server refused the ID-JAG (draft §4.4.1): wrong * `typ`, an `aud` naming a different authorization server, a `client_id` claim * that does not match the authenticated client, a bad signature, or expiry. */export class EmaRedemptionRejected extends Data.TaggedError("EmaRedemptionRejected")<{ /** The Resource Authorization Server's RFC 6749 §5.2 code, when it returned one. */ readonly error: string | undefined; readonly detail: string;}> { override get message(): string { return `The MCP server's authorization server rejected the identity assertion grant${ this.error === undefined ? "" : ` (${this.error})` }: ${this.detail}`; }} /** A token endpoint could not be reached, timed out, or answered with something * that is not an OAuth response at all. Carries no authorization verdict, so * it is retryable: the next attempt may well succeed. */export class EmaUpstreamUnavailable extends Data.TaggedError("EmaUpstreamUnavailable")<{ readonly step: "token-exchange" | "redemption"; readonly detail: string;}> { override get message(): string { return `The enterprise-managed authorization ${this.step} request failed: ${this.detail}`; }} /** What running the two-step grant can fail with. Notably NOT * `EmaGrantProfileUnsupported`: minting never inspects metadata, so it cannot * reach that verdict, and callers of `mintEnterpriseManagedAccessToken` must * not be made to write an arm for a case that cannot occur. */export type EnterpriseManagedMintError = | EmaPolicyDenied | EmaSubjectTokenRejected | EmaRedemptionRejected | EmaUpstreamUnavailable; /** Everything `runEnterpriseManagedAuthorization` can fail with: the mint * failures plus the one discovery verdict only it can produce. */export type EnterpriseManagedAuthorizationError = | EnterpriseManagedMintError | EmaGrantProfileUnsupported; // ---------------------------------------------------------------------------// Configuration// --------------------------------------------------------------------------- /** The client's registration at the enterprise IdP — the relationship that * authenticates the token-exchange request (draft §5: this is a DIFFERENT * registration from the one at the Resource Authorization Server). */export interface EnterpriseIdentityProvider { /** The IdP's token endpoint, where the RFC 8693 exchange is POSTed. */ readonly tokenUrl: string; readonly clientId: string; /** Empty or null for a public client the IdP does not authenticate. */ readonly clientSecret?: string | null;} /** The client's registration at the MCP server's Resource Authorization Server * — the credentials that authenticate the ID-JAG redemption (draft §4.4). */export interface ResourceAuthorizationServerClient { readonly tokenUrl: string; /** The Resource Authorization Server's RFC 8414 issuer identifier. Sent as * the exchange's `audience` and validated by the server as the ID-JAG's * `aud` claim, which is what stops an ID-JAG minted for one server from * being replayed at another (draft §4.4.1). */ readonly issuer: string; readonly clientId: string; readonly clientSecret?: string | null;} export interface EnterpriseManagedAuthorizationInput { readonly idp: EnterpriseIdentityProvider; readonly resourceAuthorizationServer: ResourceAuthorizationServerClient; /** The identity assertion obtained from single sign-on with the IdP. */ readonly subjectToken: string; readonly subjectTokenType?: SubjectTokenType; /** RFC 9728 resource identifier of the MCP server (EMA profile §4). */ readonly resource?: string | null; readonly scopes?: readonly string[]; readonly timeoutMs?: number; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; readonly fetch?: typeof globalThis.fetch;} /** An access token minted through the ID-JAG chain, plus the scope the Resource * Authorization Server actually granted. There is no refresh token by design * (draft §4.4.3): renewal re-runs the chain. */export interface EnterpriseManagedGrant { readonly token: OAuth2TokenResponse; /** Granted scope as echoed by the Resource Authorization Server, falling back * to what the IdP recorded in the ID-JAG. Null when neither said. */ readonly scope: string | null;} // ---------------------------------------------------------------------------// The chain// --------------------------------------------------------------------------- const exchangeFailure = (cause: OAuth2Error): EnterpriseManagedMintError => { // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuth2Error declares `message` as a field; this is a typed failure, not an unknown throwable const detail = cause.message; // RFC 6749 §5.2: `invalid_grant` is the code for a grant that is invalid, // expired or revoked — here, the identity assertion the client presented. Any // OTHER definitive code is the IdP declining to authorize this client for // this target, which is an administrator decision. if (cause.error === "invalid_grant") { return new EmaSubjectTokenRejected({ detail }); } if (cause.error !== undefined) { return new EmaPolicyDenied({ error: cause.error, detail }); } return new EmaUpstreamUnavailable({ step: "token-exchange", detail });}; const redemptionFailure = (cause: OAuth2Error): EnterpriseManagedMintError => { // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: see `exchangeFailure` above const detail = cause.message; return cause.error === undefined ? new EmaUpstreamUnavailable({ step: "redemption", detail }) : new EmaRedemptionRejected({ error: cause.error, detail });}; /** Run the two-step grant: exchange the identity assertion for an ID-JAG at the * IdP, then redeem the ID-JAG at the Resource Authorization Server. * * The ID-JAG is deliberately NOT retained. Draft §4.4.3 lets a client re-submit * a still-valid ID-JAG when only the access token expired, but that saves one * round trip at the cost of persisting a second bearer-equivalent credential * whose expiry the client would then have to track. Re-running the exchange is * the simpler correct behavior and is what §4.4.3 prescribes once the ID-JAG * itself has expired. */export const mintEnterpriseManagedAccessToken = ( input: EnterpriseManagedAuthorizationInput,): Effect.Effect<EnterpriseManagedGrant, EnterpriseManagedMintError> => Effect.gen(function* () { const grant = yield* exchangeSubjectTokenForIdJag({ tokenUrl: input.idp.tokenUrl, clientId: input.idp.clientId, clientSecret: input.idp.clientSecret, subjectToken: input.subjectToken, subjectTokenType: input.subjectTokenType ?? DEFAULT_SUBJECT_TOKEN_TYPE, audience: input.resourceAuthorizationServer.issuer, resource: input.resource, scopes: input.scopes, timeoutMs: input.timeoutMs, endpointUrlPolicy: input.endpointUrlPolicy, fetch: input.fetch, }).pipe(Effect.mapError(exchangeFailure)); // Re-request only what the IdP granted. Policy MAY narrow the set (§4.3.3), // and asking the Resource Authorization Server for more than the ID-JAG // carries would be asking it to exceed the enterprise's own decision. const grantedScopes = grant.scope === undefined ? input.scopes : grant.scope.split(/\s+/).filter(Boolean); const token = yield* redeemIdJagAssertion({ tokenUrl: input.resourceAuthorizationServer.tokenUrl, issuerUrl: input.resourceAuthorizationServer.issuer, clientId: input.resourceAuthorizationServer.clientId, clientSecret: input.resourceAuthorizationServer.clientSecret, assertion: grant.assertion, resource: input.resource, scopes: grantedScopes, timeoutMs: input.timeoutMs, endpointUrlPolicy: input.endpointUrlPolicy, fetch: input.fetch, }).pipe(Effect.mapError(redemptionFailure)); return { token, scope: token.scope ?? grant.scope ?? null, } satisfies EnterpriseManagedGrant; }).pipe( Effect.withSpan("executor.oauth.enterprise_managed", { attributes: { "executor.oauth.has_resource": input.resource != null }, }), ); /** Detect the profile on the target's discovered metadata, then run the chain. * The connect path uses this; the credential-refresh path calls * `mintEnterpriseManagedAccessToken` directly, because the profile was already * confirmed when the connection was made and re-discovering it on every token * renewal would add a round trip that can only ever confirm what is stored. */export const runEnterpriseManagedAuthorization = ( input: Omit<EnterpriseManagedAuthorizationInput, "resourceAuthorizationServer"> & { readonly authorizationServerMetadata: OAuthAuthorizationServerMetadata; readonly resourceAuthorizationServer: Omit< ResourceAuthorizationServerClient, "tokenUrl" | "issuer" >; },): Effect.Effect<EnterpriseManagedGrant, EnterpriseManagedAuthorizationError> => Effect.suspend<EnterpriseManagedGrant, EnterpriseManagedAuthorizationError, never>(() => { const metadata = input.authorizationServerMetadata; if (!supportsIdJagGrantProfile(metadata)) { return Effect.fail( new EmaGrantProfileUnsupported({ issuer: metadata.issuer, advertised: metadata.authorization_grant_profiles_supported ?? [], }), ); } return mintEnterpriseManagedAccessToken({ ...input, resourceAuthorizationServer: { ...input.resourceAuthorizationServer, tokenUrl: metadata.token_endpoint, issuer: metadata.issuer, }, }); }); // ---------------------------------------------------------------------------// Rollout gate//// Enterprise-managed authorization is shipped behind a host-owned rollout gate.// The SDK declares the PORT and stays vendor-free: no feature-flag service, no// analytics client, no network dependency of its own. A host that has one// injects an implementation through `OAuthServiceDeps`; a host that has none// (desktop, CLI, local, self-hosted) injects nothing and the profile behaves// exactly as it did before the gate existed.//// Two properties are load-bearing and are pinned by tests// (`oauth-ema-rollout.test.ts`)://// 1. The gate is consulted EXACTLY ONCE per connect, BEFORE discovery, and// NEVER after the IdP has spoken. A policy denial that could be re-routed// through a flag check would turn the flag into an escape hatch around the// very enterprise control this profile exists to enforce.//// 2. The gate is a CONNECT-time decision only. It is recorded on the// connection (`ENTERPRISE_MANAGED_PROVIDER_STATE_KEY`) and the// credential-refresh path follows the stored state, never the gate. So// turning the flag off stops NEW enterprise-managed connects and leaves// every existing managed connection renewing untouched — and no// third-party network dependency ever enters credential resolution.// --------------------------------------------------------------------------- /** Who is connecting, as far as a rollout gate needs to know. Carries identity * and catalog identifiers only — never the identity assertion, the client * secret, or any other credential material. */export interface EnterpriseManagedRolloutContext { /** The acting user, as the host identifies them. Null when the executor is * bound to an org-level caller with no individual subject. */ readonly userId: string | null; /** The organization the executor is bound to, when the host names one. */ readonly organizationId: string | null; /** The integration being connected. A spec-derived catalog slug, safe to * report; the `oauth_client` slug is deliberately NOT here, because a * user-registered client's slug is user-entered text. */ readonly integration: IntegrationSlug;} /** Why the gate withheld the enterprise-managed path. Kept structural so a host * can tell "the operator has not enabled this yet" apart from "we could not * find out", which are the same user-visible behavior but different * operational events. */export type EnterpriseManagedRolloutWithheldReason = /** The gate answered, and the answer was no. */ | "disabled" /** The gate could not reach a decision (timeout, transport failure, * unusable answer, missing configuration) and failed closed. */ | "evaluation-unavailable"; /** The gate's verdict on one connect. */export type EnterpriseManagedRolloutDecision = | { readonly kind: "enabled" } | { readonly kind: "withheld"; readonly reason: EnterpriseManagedRolloutWithheldReason }; /** What happened on a connect against a client whose grant is `id_jag`, for a * host that records rollout analytics. * * Every variant carries the context and the gate's decision and NOTHING else * that could be sensitive: there is no field here that can hold a token, an * identity assertion, a client secret, or a scope value. */export type EnterpriseManagedRolloutEvent = /** A connect reached the enterprise-managed branch. Fires for BOTH arms of * the rollout — read `decision` for which — so the funnel below it has a * denominator. */ | { readonly kind: "attempted"; readonly context: EnterpriseManagedRolloutContext; readonly decision: EnterpriseManagedRolloutDecision; } /** The ID-JAG chain completed and a connection was minted. */ | { readonly kind: "connected"; readonly context: EnterpriseManagedRolloutContext; readonly decision: EnterpriseManagedRolloutDecision; } /** The enterprise IdP declined to authorize this user for this server. */ | { readonly kind: "blocked-by-admin"; readonly context: EnterpriseManagedRolloutContext; readonly decision: EnterpriseManagedRolloutDecision; /** The IdP's RFC 6749 §5.2 error code, when it returned one. */ readonly oauthErrorCode: string | undefined; }; /** The host-owned rollout seam for enterprise-managed authorization. * * `decide` is the gate: it MUST NOT fail, because a rollout mechanism that can * fail a connect is worse than no rollout mechanism. An implementation that * cannot reach a verdict returns * `{ kind: "withheld", reason: "evaluation-unavailable" }` — failing closed — * rather than failing the effect. * * `record` is a best-effort observer. The OAuth service runs it with its * failures and defects discarded, so it can neither fail a connect nor change * its outcome no matter what the implementation does. */export interface EnterpriseManagedRollout { readonly decide: ( context: EnterpriseManagedRolloutContext, ) => Effect.Effect<EnterpriseManagedRolloutDecision>; readonly record: (event: EnterpriseManagedRolloutEvent) => Effect.Effect<void>;} /** The decision that applies when no host injected a gate: enterprise-managed * authorization is attempted, which is the behavior every host had before the * rollout seam existed. */export const ENTERPRISE_MANAGED_ROLLOUT_ENABLED: EnterpriseManagedRolloutDecision = { kind: "enabled",};
// ---------------------------------------------------------------------------
// Enterprise-Managed Authorization (EMA) — the client half.
//
// MCP "Enterprise-Managed Authorization" profiles
// draft-ietf-oauth-identity-assertion-authz-grant-04 for the case where the MCP
// client and the MCP server share an enterprise IdP. Instead of walking the
// user through per-server consent, the client:
//
// 1. holds an identity assertion from SSO with the IdP,
// 2. exchanges it at the IdP for an Identity Assertion JWT Authorization
// Grant (ID-JAG) naming the MCP server's authorization server (§4.3), and
// 3. redeems that ID-JAG at the Resource Authorization Server for an access
// token bound to the MCP server (§4.4).
//
// The IdP evaluates administrator policy at step 2, which is the whole point of
// the profile (§7.2): the enterprise decides which users may reach which
// servers with which scopes. That makes the error taxonomy below load-bearing
// rather than cosmetic — see `EmaPolicyDenied`.
//
// This module owns the chain only. Discovery lives in `./oauth-discovery`, the
// two token-endpoint round trips in `./oauth-helpers`, and persistence in the
// executor's credential lifecycle.
// ---------------------------------------------------------------------------
import { Data, Effect, Option, Schema } from "effect";
import { IntegrationSlug, OAuthClientSlug, Owner } from "./ids";
import {
DEFAULT_SUBJECT_TOKEN_TYPE,
SubjectTokenTypeSchema,
type SubjectTokenType,
} from "./oauth-client";
import {
ID_JAG_GRANT_PROFILE,
supportsIdJagGrantProfile,
type OAuthAuthorizationServerMetadata,
} from "./oauth-discovery";
import {
exchangeSubjectTokenForIdJag,
redeemIdJagAssertion,
type OAuth2Error,
type OAuth2TokenResponse,
type OAuthEndpointUrlPolicy,
} from "./oauth-helpers";
// ---------------------------------------------------------------------------
// Persisted connection state
//
// Everything the renewal path needs that is NOT already a column on the
// connection or its `oauth_client` row. The `oauth_client` supplies the client's
// registration at the Resource Authorization Server (id/secret/token URL/
// resource); the connection supplies the granted scope and the identity
// assertion (stored in the same credential-provider item a refresh token would
// occupy — it plays exactly that role here). What remains is the pointer to the
// IdP registration and the discovered `audience`, which cannot be derived from
// either.
// ---------------------------------------------------------------------------
export const EnterpriseManagedConnectionStateSchema = Schema.Struct({
/** `oauth_client` slug of the client's registration at the enterprise IdP. */
idpClient: OAuthClientSlug,
idpClientOwner: Owner,
/** The Resource Authorization Server's issuer identifier, as discovered from
* its RFC 8414 metadata when the connection was made. */
audience: Schema.String,
subjectTokenType: SubjectTokenTypeSchema,
}).annotate({
identifier: "EnterpriseManagedConnectionState",
description:
"Enterprise-managed authorization wiring persisted on a connection: which IdP registration mints its ID-JAG, and the audience that ID-JAG must name.",
});
export type EnterpriseManagedConnectionState = typeof EnterpriseManagedConnectionStateSchema.Type;
/** The key `EnterpriseManagedConnectionState` occupies inside a connection's
* `provider_state` JSON, alongside the other core-owned keys. */
export const ENTERPRISE_MANAGED_PROVIDER_STATE_KEY = "enterpriseManaged";
const decodeEnterpriseManagedProviderState = Schema.decodeUnknownOption(
Schema.Struct({
[ENTERPRISE_MANAGED_PROVIDER_STATE_KEY]: EnterpriseManagedConnectionStateSchema,
}),
);
/** Read the enterprise-managed wiring off a connection's decoded
* `provider_state`, or null when the connection is not enterprise-managed.
* A malformed entry reads as absent — the refresh path then fails loudly with
* "not enterprise-managed" rather than half-running the chain on fragments. */
export const enterpriseManagedStateFrom = (
providerState: unknown,
): EnterpriseManagedConnectionState | null =>
Option.match(decodeEnterpriseManagedProviderState(providerState), {
onNone: () => null,
onSome: (decoded) => decoded[ENTERPRISE_MANAGED_PROVIDER_STATE_KEY],
});
// ---------------------------------------------------------------------------
// Errors
//
// The distinctions here drive product behavior, so they are separate tags
// rather than one error carrying a code:
//
// - `EmaGrantProfileUnsupported` is the ONLY failure that permits falling
// back to the ordinary interactive per-server OAuth flow. The server simply
// does not implement the profile.
// - `EmaPolicyDenied` and `EmaSubjectTokenRejected` MUST NOT fall back.
// Falling back would let a user route around the enterprise policy the IdP
// just enforced, which is precisely the control this profile exists to
// provide.
// ---------------------------------------------------------------------------
/** The Resource Authorization Server does not advertise the ID-JAG grant
* profile in its RFC 8414 metadata (draft §7.2). Enterprise-managed
* authorization is not available for this server; the caller MAY fall back to
* the ordinary interactive authorization-code flow. */
export class EmaGrantProfileUnsupported extends Data.TaggedError("EmaGrantProfileUnsupported")<{
readonly issuer: string;
readonly advertised: readonly string[];
}> {
override get message(): string {
return `The authorization server ${this.issuer} does not advertise ${ID_JAG_GRANT_PROFILE}${
this.advertised.length > 0 ? ` (advertised: ${this.advertised.join(", ")})` : ""
}.`;
}
}
/** The enterprise IdP refused to mint an ID-JAG for this client, user, resource
* or scope set. This is an administrator decision, not a credential problem:
* the user cannot fix it by signing in again, and the client MUST NOT offer
* the interactive per-server flow as an alternative route. Surface it as
* blocked-by-admin and stop. */
export class EmaPolicyDenied extends Data.TaggedError("EmaPolicyDenied")<{
/** The IdP's RFC 6749 §5.2 error code (`unauthorized_client`, `access_denied`,
* `invalid_target`, `invalid_scope`, `invalid_client`, …). */
readonly error: string;
readonly detail: string;
}> {
override get message(): string {
return `Your organization's identity provider did not authorize this MCP server (${this.error}): ${this.detail}`;
}
}
/** The IdP rejected the identity assertion itself (RFC 6749 `invalid_grant`):
* expired, revoked, or issued for a different client. The user must sign in
* with the enterprise IdP again to obtain a fresh assertion. */
export class EmaSubjectTokenRejected extends Data.TaggedError("EmaSubjectTokenRejected")<{
readonly detail: string;
}> {
override get message(): string {
return `The enterprise identity assertion was rejected and a new single sign-on is required: ${this.detail}`;
}
}
/** The Resource Authorization Server refused the ID-JAG (draft §4.4.1): wrong
* `typ`, an `aud` naming a different authorization server, a `client_id` claim
* that does not match the authenticated client, a bad signature, or expiry. */
export class EmaRedemptionRejected extends Data.TaggedError("EmaRedemptionRejected")<{
/** The Resource Authorization Server's RFC 6749 §5.2 code, when it returned one. */
readonly error: string | undefined;
readonly detail: string;
}> {
override get message(): string {
return `The MCP server's authorization server rejected the identity assertion grant${
this.error === undefined ? "" : ` (${this.error})`
}: ${this.detail}`;
}
}
/** A token endpoint could not be reached, timed out, or answered with something
* that is not an OAuth response at all. Carries no authorization verdict, so
* it is retryable: the next attempt may well succeed. */
export class EmaUpstreamUnavailable extends Data.TaggedError("EmaUpstreamUnavailable")<{
readonly step: "token-exchange" | "redemption";
readonly detail: string;
}> {
override get message(): string {
return `The enterprise-managed authorization ${this.step} request failed: ${this.detail}`;
}
}
/** What running the two-step grant can fail with. Notably NOT
* `EmaGrantProfileUnsupported`: minting never inspects metadata, so it cannot
* reach that verdict, and callers of `mintEnterpriseManagedAccessToken` must
* not be made to write an arm for a case that cannot occur. */
export type EnterpriseManagedMintError =
| EmaPolicyDenied
| EmaSubjectTokenRejected
| EmaRedemptionRejected
| EmaUpstreamUnavailable;
/** Everything `runEnterpriseManagedAuthorization` can fail with: the mint
* failures plus the one discovery verdict only it can produce. */
export type EnterpriseManagedAuthorizationError =
| EnterpriseManagedMintError
| EmaGrantProfileUnsupported;
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
/** The client's registration at the enterprise IdP — the relationship that
* authenticates the token-exchange request (draft §5: this is a DIFFERENT
* registration from the one at the Resource Authorization Server). */
export interface EnterpriseIdentityProvider {
/** The IdP's token endpoint, where the RFC 8693 exchange is POSTed. */
readonly tokenUrl: string;
readonly clientId: string;
/** Empty or null for a public client the IdP does not authenticate. */
readonly clientSecret?: string | null;
}
/** The client's registration at the MCP server's Resource Authorization Server
* — the credentials that authenticate the ID-JAG redemption (draft §4.4). */
export interface ResourceAuthorizationServerClient {
readonly tokenUrl: string;
/** The Resource Authorization Server's RFC 8414 issuer identifier. Sent as
* the exchange's `audience` and validated by the server as the ID-JAG's
* `aud` claim, which is what stops an ID-JAG minted for one server from
* being replayed at another (draft §4.4.1). */
readonly issuer: string;
readonly clientId: string;
readonly clientSecret?: string | null;
}
export interface EnterpriseManagedAuthorizationInput {
readonly idp: EnterpriseIdentityProvider;
readonly resourceAuthorizationServer: ResourceAuthorizationServerClient;
/** The identity assertion obtained from single sign-on with the IdP. */
readonly subjectToken: string;
readonly subjectTokenType?: SubjectTokenType;
/** RFC 9728 resource identifier of the MCP server (EMA profile §4). */
readonly resource?: string | null;
readonly scopes?: readonly string[];
readonly timeoutMs?: number;
readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
readonly fetch?: typeof globalThis.fetch;
}
/** An access token minted through the ID-JAG chain, plus the scope the Resource
* Authorization Server actually granted. There is no refresh token by design
* (draft §4.4.3): renewal re-runs the chain. */
export interface EnterpriseManagedGrant {
readonly token: OAuth2TokenResponse;
/** Granted scope as echoed by the Resource Authorization Server, falling back
* to what the IdP recorded in the ID-JAG. Null when neither said. */
readonly scope: string | null;
}
// ---------------------------------------------------------------------------
// The chain
// ---------------------------------------------------------------------------
const exchangeFailure = (cause: OAuth2Error): EnterpriseManagedMintError => {
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuth2Error declares `message` as a field; this is a typed failure, not an unknown throwable
const detail = cause.message;
// RFC 6749 §5.2: `invalid_grant` is the code for a grant that is invalid,
// expired or revoked — here, the identity assertion the client presented. Any
// OTHER definitive code is the IdP declining to authorize this client for
// this target, which is an administrator decision.
if (cause.error === "invalid_grant") {
return new EmaSubjectTokenRejected({ detail });
}
if (cause.error !== undefined) {
return new EmaPolicyDenied({ error: cause.error, detail });
}
return new EmaUpstreamUnavailable({ step: "token-exchange", detail });
};
const redemptionFailure = (cause: OAuth2Error): EnterpriseManagedMintError => {
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: see `exchangeFailure` above
const detail = cause.message;
return cause.error === undefined
? new EmaUpstreamUnavailable({ step: "redemption", detail })
: new EmaRedemptionRejected({ error: cause.error, detail });
};
/** Run the two-step grant: exchange the identity assertion for an ID-JAG at the
* IdP, then redeem the ID-JAG at the Resource Authorization Server.
*
* The ID-JAG is deliberately NOT retained. Draft §4.4.3 lets a client re-submit
* a still-valid ID-JAG when only the access token expired, but that saves one
* round trip at the cost of persisting a second bearer-equivalent credential
* whose expiry the client would then have to track. Re-running the exchange is
* the simpler correct behavior and is what §4.4.3 prescribes once the ID-JAG
* itself has expired. */
export const mintEnterpriseManagedAccessToken = (
input: EnterpriseManagedAuthorizationInput,
): Effect.Effect<EnterpriseManagedGrant, EnterpriseManagedMintError> =>
Effect.gen(function* () {
const grant = yield* exchangeSubjectTokenForIdJag({
tokenUrl: input.idp.tokenUrl,
clientId: input.idp.clientId,
clientSecret: input.idp.clientSecret,
subjectToken: input.subjectToken,
subjectTokenType: input.subjectTokenType ?? DEFAULT_SUBJECT_TOKEN_TYPE,
audience: input.resourceAuthorizationServer.issuer,
resource: input.resource,
scopes: input.scopes,
timeoutMs: input.timeoutMs,
endpointUrlPolicy: input.endpointUrlPolicy,
fetch: input.fetch,
}).pipe(Effect.mapError(exchangeFailure));
// Re-request only what the IdP granted. Policy MAY narrow the set (§4.3.3),
// and asking the Resource Authorization Server for more than the ID-JAG
// carries would be asking it to exceed the enterprise's own decision.
const grantedScopes =
grant.scope === undefined ? input.scopes : grant.scope.split(/\s+/).filter(Boolean);
const token = yield* redeemIdJagAssertion({
tokenUrl: input.resourceAuthorizationServer.tokenUrl,
issuerUrl: input.resourceAuthorizationServer.issuer,
clientId: input.resourceAuthorizationServer.clientId,
clientSecret: input.resourceAuthorizationServer.clientSecret,
assertion: grant.assertion,
resource: input.resource,
scopes: grantedScopes,
timeoutMs: input.timeoutMs,
endpointUrlPolicy: input.endpointUrlPolicy,
fetch: input.fetch,
}).pipe(Effect.mapError(redemptionFailure));
return {
token,
scope: token.scope ?? grant.scope ?? null,
} satisfies EnterpriseManagedGrant;
}).pipe(
Effect.withSpan("executor.oauth.enterprise_managed", {
attributes: { "executor.oauth.has_resource": input.resource != null },
}),
);
/** Detect the profile on the target's discovered metadata, then run the chain.
* The connect path uses this; the credential-refresh path calls
* `mintEnterpriseManagedAccessToken` directly, because the profile was already
* confirmed when the connection was made and re-discovering it on every token
* renewal would add a round trip that can only ever confirm what is stored. */
export const runEnterpriseManagedAuthorization = (
input: Omit<EnterpriseManagedAuthorizationInput, "resourceAuthorizationServer"> & {
readonly authorizationServerMetadata: OAuthAuthorizationServerMetadata;
readonly resourceAuthorizationServer: Omit<
ResourceAuthorizationServerClient,
"tokenUrl" | "issuer"
>;
},
): Effect.Effect<EnterpriseManagedGrant, EnterpriseManagedAuthorizationError> =>
Effect.suspend<EnterpriseManagedGrant, EnterpriseManagedAuthorizationError, never>(() => {
const metadata = input.authorizationServerMetadata;
if (!supportsIdJagGrantProfile(metadata)) {
return Effect.fail(
new EmaGrantProfileUnsupported({
issuer: metadata.issuer,
advertised: metadata.authorization_grant_profiles_supported ?? [],
}),
);
}
return mintEnterpriseManagedAccessToken({
...input,
resourceAuthorizationServer: {
...input.resourceAuthorizationServer,
tokenUrl: metadata.token_endpoint,
issuer: metadata.issuer,
},
});
});
// ---------------------------------------------------------------------------
// Rollout gate
//
// Enterprise-managed authorization is shipped behind a host-owned rollout gate.
// The SDK declares the PORT and stays vendor-free: no feature-flag service, no
// analytics client, no network dependency of its own. A host that has one
// injects an implementation through `OAuthServiceDeps`; a host that has none
// (desktop, CLI, local, self-hosted) injects nothing and the profile behaves
// exactly as it did before the gate existed.
//
// Two properties are load-bearing and are pinned by tests
// (`oauth-ema-rollout.test.ts`):
//
// 1. The gate is consulted EXACTLY ONCE per connect, BEFORE discovery, and
// NEVER after the IdP has spoken. A policy denial that could be re-routed
// through a flag check would turn the flag into an escape hatch around the
// very enterprise control this profile exists to enforce.
//
// 2. The gate is a CONNECT-time decision only. It is recorded on the
// connection (`ENTERPRISE_MANAGED_PROVIDER_STATE_KEY`) and the
// credential-refresh path follows the stored state, never the gate. So
// turning the flag off stops NEW enterprise-managed connects and leaves
// every existing managed connection renewing untouched — and no
// third-party network dependency ever enters credential resolution.
// ---------------------------------------------------------------------------
/** Who is connecting, as far as a rollout gate needs to know. Carries identity
* and catalog identifiers only — never the identity assertion, the client
* secret, or any other credential material. */
export interface EnterpriseManagedRolloutContext {
/** The acting user, as the host identifies them. Null when the executor is
* bound to an org-level caller with no individual subject. */
readonly userId: string | null;
/** The organization the executor is bound to, when the host names one. */
readonly organizationId: string | null;
/** The integration being connected. A spec-derived catalog slug, safe to
* report; the `oauth_client` slug is deliberately NOT here, because a
* user-registered client's slug is user-entered text. */
readonly integration: IntegrationSlug;
}
/** Why the gate withheld the enterprise-managed path. Kept structural so a host
* can tell "the operator has not enabled this yet" apart from "we could not
* find out", which are the same user-visible behavior but different
* operational events. */
export type EnterpriseManagedRolloutWithheldReason =
/** The gate answered, and the answer was no. */
| "disabled"
/** The gate could not reach a decision (timeout, transport failure,
* unusable answer, missing configuration) and failed closed. */
| "evaluation-unavailable";
/** The gate's verdict on one connect. */
export type EnterpriseManagedRolloutDecision =
| { readonly kind: "enabled" }
| { readonly kind: "withheld"; readonly reason: EnterpriseManagedRolloutWithheldReason };
/** What happened on a connect against a client whose grant is `id_jag`, for a
* host that records rollout analytics.
*
* Every variant carries the context and the gate's decision and NOTHING else
* that could be sensitive: there is no field here that can hold a token, an
* identity assertion, a client secret, or a scope value. */
export type EnterpriseManagedRolloutEvent =
/** A connect reached the enterprise-managed branch. Fires for BOTH arms of
* the rollout — read `decision` for which — so the funnel below it has a
* denominator. */
| {
readonly kind: "attempted";
readonly context: EnterpriseManagedRolloutContext;
readonly decision: EnterpriseManagedRolloutDecision;
}
/** The ID-JAG chain completed and a connection was minted. */
| {
readonly kind: "connected";
readonly context: EnterpriseManagedRolloutContext;
readonly decision: EnterpriseManagedRolloutDecision;
}
/** The enterprise IdP declined to authorize this user for this server. */
| {
readonly kind: "blocked-by-admin";
readonly context: EnterpriseManagedRolloutContext;
readonly decision: EnterpriseManagedRolloutDecision;
/** The IdP's RFC 6749 §5.2 error code, when it returned one. */
readonly oauthErrorCode: string | undefined;
};
/** The host-owned rollout seam for enterprise-managed authorization.
*
* `decide` is the gate: it MUST NOT fail, because a rollout mechanism that can
* fail a connect is worse than no rollout mechanism. An implementation that
* cannot reach a verdict returns
* `{ kind: "withheld", reason: "evaluation-unavailable" }` — failing closed —
* rather than failing the effect.
*
* `record` is a best-effort observer. The OAuth service runs it with its
* failures and defects discarded, so it can neither fail a connect nor change
* its outcome no matter what the implementation does. */
export interface EnterpriseManagedRollout {
readonly decide: (
context: EnterpriseManagedRolloutContext,
) => Effect.Effect<EnterpriseManagedRolloutDecision>;
readonly record: (event: EnterpriseManagedRolloutEvent) => Effect.Effect<void>;
}
/** The decision that applies when no host injected a gate: enterprise-managed
* authorization is attempted, which is the behavior every host had before the
* rollout seam existed. */
export const ENTERPRISE_MANAGED_ROLLOUT_ENABLED: EnterpriseManagedRolloutDecision = {
kind: "enabled",
};