From 2a6e5322c882d52ea567c2a704944636e1d14968 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:38:14 -0700 Subject: [PATCH 01/11] Add ID-JAG discovery, token exchange, and jwt-bearer redemption helpers --- packages/core/sdk/src/oauth-client.ts | 54 +++- packages/core/sdk/src/oauth-discovery.ts | 22 ++ packages/core/sdk/src/oauth-ema.ts | 365 +++++++++++++++++++++++ packages/core/sdk/src/oauth-helpers.ts | 287 +++++++++++++++++- 4 files changed, 726 insertions(+), 2 deletions(-) create mode 100644 packages/core/sdk/src/oauth-ema.ts diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 5b1800e1b6..c4ca773038 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -13,6 +13,31 @@ import { type Owner, } from "./ids"; +/** RFC 8693 §3 security token type identifiers usable as a `subject_token_type` + * when exchanging an enterprise identity assertion for an ID-JAG. The id-jag + * draft §4.3 profiles `id_token` and `saml2` for identity assertions and + * `refresh_token` for the re-issue path; `access_token` is the RFC 8693 base + * type some enterprise IdPs mint their SSO assertion as. */ +export const SUBJECT_TOKEN_TYPES = [ + "urn:ietf:params:oauth:token-type:id_token", + "urn:ietf:params:oauth:token-type:saml2", + "urn:ietf:params:oauth:token-type:refresh_token", + "urn:ietf:params:oauth:token-type:access_token", +] as const; + +export const SubjectTokenTypeSchema = Schema.Literals(SUBJECT_TOKEN_TYPES).annotate({ + identifier: "SubjectTokenType", + description: + "RFC 8693 identifier for the security token presented as `subject_token` when exchanging an enterprise identity assertion for an ID-JAG.", +}); +export type SubjectTokenType = typeof SubjectTokenTypeSchema.Type; + +/** The `subject_token_type` used when a caller does not state one. An OpenID + * Connect ID Token is the identity assertion the id-jag draft §4.3 requires + * every IdP to accept, so it is the only defensible default. */ +export const DEFAULT_SUBJECT_TOKEN_TYPE: SubjectTokenType = + "urn:ietf:params:oauth:token-type:id_token"; + /* The v2 OAuth surface contracts. OAuth is a credential mechanism, not an * integration type. A client is a registered app; running its flow mints a * Connection. The client is self-contained (carries its own endpoints) and @@ -23,7 +48,13 @@ import { * `oauth-helpers` / `oauth-discovery` / `oauth-service`; these are the public * input/output shapes the executor's `oauth.*` namespace speaks. */ -export type OAuthGrant = "authorization_code" | "client_credentials"; +/** `id_jag` is the MCP Enterprise-Managed Authorization profile + * (draft-ietf-oauth-identity-assertion-authz-grant §4): the client presents an + * enterprise identity assertion instead of walking the user through consent. + * Such a client's `clientId`/`clientSecret`/`tokenUrl` are its registration at + * the MCP server's Resource Authorization Server — the IdP registration is a + * second client, named on the connect request. */ +export type OAuthGrant = "authorization_code" | "client_credentials" | "id_jag"; /** Provider OAuth config an integration declares as one of its auth templates — * what to request. (The flow itself runs off the self-contained OAuthClient.) @@ -220,6 +251,27 @@ export interface OAuthStartInput { readonly newConnection?: boolean; /** Browser-facing callback URL for this flow. Defaults to the executor's configured redirectUri. */ readonly redirectUri?: string | null; + /** Enterprise-managed authorization inputs, required when `client.grant` is + * `id_jag` and ignored otherwise. Carries the SECOND client registration + * (the one at the enterprise IdP) and the identity assertion the user + * already holds from single sign-on. */ + readonly enterprise?: EnterpriseManagedStartInput; +} + +/** What an enterprise-managed connect needs beyond the ordinary start inputs. + * The subject token is supplied by the caller because "where the identity + * assertion comes from" is a host concern: a desktop app holds its own SSO + * tokens, a hosted deployment holds the session's. */ +export interface EnterpriseManagedStartInput { + /** `oauth_client` slug of the client's registration at the enterprise IdP. */ + readonly idpClient: OAuthClientSlug; + readonly idpClientOwner: Owner; + /** The identity assertion from single sign-on with the IdP (an OIDC ID token + * by default). Persisted through the credential provider so token renewal + * needs no further user interaction. */ + readonly subjectToken: string; + /** RFC 8693 §3 type of `subjectToken`. Defaults to an OIDC ID token. */ + readonly subjectTokenType?: SubjectTokenType; } export interface OAuthCompleteInput { diff --git a/packages/core/sdk/src/oauth-discovery.ts b/packages/core/sdk/src/oauth-discovery.ts index 25786012cf..a3bdb46ff4 100644 --- a/packages/core/sdk/src/oauth-discovery.ts +++ b/packages/core/sdk/src/oauth-discovery.ts @@ -82,9 +82,31 @@ export const OAuthAuthorizationServerMetadataSchema = Schema.Struct({ introspection_endpoint: Schema.optional(Schema.String), userinfo_endpoint: Schema.optional(Schema.String), id_token_signing_alg_values_supported: Schema.optional(StringArray), + /** draft-ietf-oauth-identity-assertion-authz-grant-04 §7.2 — the + * authorization grant profiles this Resource Authorization Server + * implements. Advertising a profile says only that the server implements + * its processing rules; it promises nothing about any particular issuer, + * client, or subject being accepted. */ + authorization_grant_profiles_supported: Schema.optional(StringArray), }).annotate({ identifier: "OAuthAuthorizationServerMetadata" }); export type OAuthAuthorizationServerMetadata = typeof OAuthAuthorizationServerMetadataSchema.Type; +/** draft-ietf-oauth-identity-assertion-authz-grant-04 §7.2 — the profile + * identifier a Resource Authorization Server advertises when it can process + * an Identity Assertion JWT Authorization Grant (ID-JAG). */ +export const ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"; + +/** Whether a Resource Authorization Server advertises the ID-JAG grant profile + * (§7.2). This is the ONLY discovery signal that gates enterprise-managed + * authorization: a server that stays silent gets the ordinary interactive + * flow. It is deliberately not inferred from `grant_types_supported` + * containing `jwt-bearer` — that grant type predates this profile and says + * nothing about ID-JAG processing rules. */ +export const supportsIdJagGrantProfile = ( + metadata: Pick, +): boolean => + metadata.authorization_grant_profiles_supported?.includes(ID_JAG_GRANT_PROFILE) === true; + export type DynamicClientMetadata = { readonly client_name?: string; readonly redirect_uris: readonly string[]; diff --git a/packages/core/sdk/src/oauth-ema.ts b/packages/core/sdk/src/oauth-ema.ts new file mode 100644 index 0000000000..a05ba7aa25 --- /dev/null +++ b/packages/core/sdk/src/oauth-ema.ts @@ -0,0 +1,365 @@ +// --------------------------------------------------------------------------- +// 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 { 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 ClientAuthMethod, + 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: Schema.String, + 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}`; + } +} + +export type EnterpriseManagedAuthorizationError = + | EmaGrantProfileUnsupported + | EmaPolicyDenied + | EmaSubjectTokenRejected + | EmaRedemptionRejected + | EmaUpstreamUnavailable; + +/** Whether a failure leaves the ordinary interactive OAuth flow available. + * Exactly one failure mode does. Everything else is either an enterprise + * policy decision that must not be routed around, or a condition an + * interactive flow would not fix. */ +export const permitsInteractiveFallback = ( + error: EnterpriseManagedAuthorizationError, +): error is EmaGrantProfileUnsupported => error._tag === "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 issuerUrl?: string | null; + readonly clientId: string; + /** Empty or null for a public client the IdP does not authenticate. */ + readonly clientSecret?: string | null; + readonly clientAuth?: ClientAuthMethod; +} + +/** 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; + readonly clientAuth?: ClientAuthMethod; +} + +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): EnterpriseManagedAuthorizationError => { + // 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: cause.message }); + } + if (cause.error !== undefined) { + return new EmaPolicyDenied({ error: cause.error, detail: cause.message }); + } + return new EmaUpstreamUnavailable({ step: "token-exchange", detail: cause.message }); +}; + +const redemptionFailure = (cause: OAuth2Error): EnterpriseManagedAuthorizationError => + cause.error === undefined + ? new EmaUpstreamUnavailable({ step: "redemption", detail: cause.message }) + : new EmaRedemptionRejected({ error: cause.error, detail: cause.message }); + +/** 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 => + Effect.gen(function* () { + const grant = yield* exchangeSubjectTokenForIdJag({ + tokenUrl: input.idp.tokenUrl, + issuerUrl: input.idp.issuerUrl, + clientId: input.idp.clientId, + clientSecret: input.idp.clientSecret, + clientAuth: input.idp.clientAuth, + 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, + clientAuth: input.resourceAuthorizationServer.clientAuth, + 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 & { + readonly authorizationServerMetadata: OAuthAuthorizationServerMetadata; + readonly resourceAuthorizationServer: Omit< + ResourceAuthorizationServerClient, + "tokenUrl" | "issuer" + >; + }, +): Effect.Effect => + Effect.suspend(() => { + 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, + }, + }); + }); diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index f6ccc7a53b..4c333eeab2 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -19,6 +19,8 @@ import { Data, Effect, Option, Predicate, Schema } from "effect"; import * as oauth from "oauth4webapi"; +import type { SubjectTokenType } from "./oauth-client"; + // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- @@ -58,6 +60,20 @@ export const OAUTH2_REFRESH_SKEW_MS = 60_000; /** Default token-endpoint timeout. */ export const OAUTH2_DEFAULT_TIMEOUT_MS = 20_000; +/** RFC 8693 §2.1 token-exchange grant. */ +export const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; + +/** RFC 7523 §2.1 JWT bearer authorization grant — how an ID-JAG is redeemed + * at the Resource Authorization Server (id-jag draft §4.4). */ +export const JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + +/** id-jag draft §4.3 `requested_token_type` / §4.3.4 `issued_token_type`. */ +export const ID_JAG_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag"; + +/** id-jag draft §4.3.4: an ID-JAG is not an OAuth access token, so the token + * exchange response MUST carry this `token_type` sentinel. */ +export const ID_JAG_TOKEN_TYPE_SENTINEL = "N_A"; + export interface OAuthEndpointUrlPolicy { readonly allowHttp?: boolean; } @@ -439,7 +455,12 @@ const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect => + Effect.promise(async () => { + const text = await Promise.resolve() + .then(() => response.clone().text()) + .then( + (value) => value, + () => "", + ); + const envelope = text.length > 0 ? decodeTokenErrorEnvelope(safeJson(text)) : Option.none(); + const summary = await tokenEndpointHttpSummary(response); + return Option.match(envelope, { + onNone: () => new OAuth2Error({ message: `${fallbackMessage} (${summary})` }), + onSome: (parsed) => + new OAuth2Error({ + message: `${fallbackMessage}: ${parsed.error}${ + parsed.error_description ? ` — ${parsed.error_description}` : "" + } (${summary})`, + error: parsed.error, + }), + }); + }).pipe(Effect.flatMap((error) => Effect.fail(error))); + +/** Structurally probe an untrusted upstream body. Returns `undefined` rather + * than a fabricated value when it is not JSON; the caller's schema decode + * decides what an absent envelope means. */ +const safeJson = (text: string): unknown => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing an untrusted token-endpoint error body; unparseable means "no RFC 6749 envelope" + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: same untrusted-body probe; the value is immediately decoded through TokenErrorEnvelopeSchema + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +}; + +export type ExchangeSubjectTokenForIdJagInput = { + /** The enterprise IdP's token endpoint. */ + readonly tokenUrl: string; + readonly issuerUrl?: string | null; + /** The client's registration AT THE IdP — a different relationship from its + * registration at the Resource Authorization Server (id-jag draft §5). */ + readonly clientId: string; + readonly clientSecret?: string | null; + readonly clientAuth?: ClientAuthMethod; + /** The identity assertion (or IdP refresh token) standing in for the user. */ + readonly subjectToken: string; + readonly subjectTokenType: SubjectTokenType; + /** REQUIRED — the issuer identifier of the Resource Authorization Server + * (id-jag draft §4.3; EMA profile §4 narrows it to exactly that). */ + readonly audience: string; + /** OPTIONAL RFC 8707 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; +}; + +/** Exchange an enterprise identity assertion for an ID-JAG at the IdP's token + * endpoint (id-jag draft §4.3). + * + * The response is validated STRICTLY: an `issued_token_type` other than the + * id-jag URN, or a `token_type` other than `N_A`, means the IdP answered with + * something that is not an authorization grant. Accepting it would hand a + * bearer token to a Resource Authorization Server as if it were a signed + * assertion, so those responses fail rather than being coerced. */ +export const exchangeSubjectTokenForIdJag = ( + input: ExchangeSubjectTokenForIdJagInput, +): Effect.Effect => + Effect.gen(function* () { + const response = yield* Effect.tryPromise({ + try: async () => { + const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { + endpointUrlPolicy: input.endpointUrlPolicy, + }); + const client: oauth.Client = { client_id: input.clientId }; + const clientAuth = pickClientAuth( + input.clientSecret, + input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, + ); + const params = new URLSearchParams({ + requested_token_type: ID_JAG_TOKEN_TYPE, + audience: input.audience, + subject_token: input.subjectToken, + subject_token_type: input.subjectTokenType, + }); + if (input.resource) params.set("resource", input.resource); + if (input.scopes && input.scopes.length > 0) { + params.set("scope", input.scopes.join(" ")); + } + return await oauth.genericTokenEndpointRequest( + as, + client, + clientAuth, + TOKEN_EXCHANGE_GRANT_TYPE, + params, + oauth4webapiRequestOptions( + input.tokenUrl, + input.timeoutMs, + input.endpointUrlPolicy, + input.fetch, + ), + ); + }, + catch: (cause) => cause, + }).pipe(Effect.catch(failOAuth2WithHttpSummary)); + + if (!response.ok) { + return yield* failOAuth2FromErrorResponse(response, "ID-JAG token exchange was rejected"); + } + + const body = yield* Effect.promise(() => + response + .clone() + .json() + .then( + (value: unknown) => value, + () => null, + ), + ); + const parsed = yield* decodeIdJagResponse(body).pipe( + Effect.mapError( + (cause) => + new OAuth2Error({ + message: "ID-JAG token exchange response did not match RFC 8693 §2.2.1", + cause, + }), + ), + ); + if (parsed.issued_token_type !== ID_JAG_TOKEN_TYPE) { + return yield* new OAuth2Error({ + message: `ID-JAG token exchange returned issued_token_type "${parsed.issued_token_type}", expected "${ID_JAG_TOKEN_TYPE}"`, + }); + } + if (parsed.token_type !== ID_JAG_TOKEN_TYPE_SENTINEL) { + return yield* new OAuth2Error({ + message: `ID-JAG token exchange returned token_type "${parsed.token_type}", expected "${ID_JAG_TOKEN_TYPE_SENTINEL}"`, + }); + } + return { + assertion: parsed.access_token, + ...(parsed.scope === undefined ? {} : { scope: parsed.scope }), + ...(parsed.expires_in === undefined ? {} : { expiresIn: parsed.expires_in }), + } satisfies IdJagGrant; + }).pipe( + withTokenRequestSpan({ + grantType: TOKEN_EXCHANGE_GRANT_TYPE, + tokenUrl: input.tokenUrl, + clientAuth: input.clientAuth, + hasResource: input.resource != null, + }), + ); + +// --------------------------------------------------------------------------- +// RFC 7523 JWT bearer redemption — present the ID-JAG at the Resource +// Authorization Server (id-jag draft §4.4). +// --------------------------------------------------------------------------- + +export type RedeemIdJagInput = { + /** The Resource Authorization Server's token endpoint. */ + readonly tokenUrl: string; + readonly issuerUrl?: string | null; + /** The client's registration AT THE RESOURCE AUTHORIZATION SERVER. The ID-JAG's + * `client_id` claim names this same client (§4.4.1 client continuity). */ + readonly clientId: string; + readonly clientSecret?: string | null; + readonly clientAuth?: ClientAuthMethod; + readonly assertion: string; + readonly resource?: string | null; + readonly scopes?: readonly string[]; + readonly timeoutMs?: number; + readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; + readonly fetch?: typeof globalThis.fetch; +}; + +/** Redeem an ID-JAG for an access token audience-restricted to the MCP server + * (id-jag draft §4.4). The response IS an ordinary OAuth token response, so it + * goes through the same processing as every other grant here. Per §4.4.3 the + * server SHOULD NOT issue a refresh token; when one arrives anyway it is + * simply not persisted — the ID-JAG chain is the renewal path. */ +export const redeemIdJagAssertion = ( + input: RedeemIdJagInput, +): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { + endpointUrlPolicy: input.endpointUrlPolicy, + }); + const client: oauth.Client = { client_id: input.clientId }; + const clientAuth = pickClientAuth( + input.clientSecret, + input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, + ); + const params = new URLSearchParams({ assertion: input.assertion }); + if (input.resource) params.set("resource", input.resource); + if (input.scopes && input.scopes.length > 0) { + params.set("scope", input.scopes.join(" ")); + } + const response = await oauth.genericTokenEndpointRequest( + as, + client, + clientAuth, + JWT_BEARER_GRANT_TYPE, + params, + oauth4webapiRequestOptions( + input.tokenUrl, + input.timeoutMs, + input.endpointUrlPolicy, + input.fetch, + ), + ); + return await processTokenEndpointResponse(as, client, response); + }, + catch: (cause) => cause, + }).pipe( + Effect.catch(failOAuth2WithHttpSummary), + withTokenRequestSpan({ + grantType: JWT_BEARER_GRANT_TYPE, + tokenUrl: input.tokenUrl, + clientAuth: input.clientAuth, + hasResource: input.resource != null, + }), + ); + // --------------------------------------------------------------------------- // Refresh-needed predicate // --------------------------------------------------------------------------- From 0cdde85c77795fa4cecd51ca892ac59033ae710e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:38:27 -0700 Subject: [PATCH 02/11] Wire the enterprise-managed grant through connect and credential refresh --- packages/core/api/src/oauth/api.ts | 18 +- packages/core/sdk/src/errors.ts | 6 + packages/core/sdk/src/executor.ts | 256 +++++++++++++++++++++---- packages/core/sdk/src/index.ts | 5 + packages/core/sdk/src/oauth-service.ts | 197 ++++++++++++++++++- packages/core/sdk/src/shared.ts | 5 + 6 files changed, 443 insertions(+), 44 deletions(-) diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index 6a54d46426..e3eb0c7bc3 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -29,6 +29,7 @@ import { OAuthState, Owner, ProviderKey, + SubjectTokenTypeSchema, } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- @@ -62,7 +63,7 @@ const CreateClientPayload = Schema.Struct({ slug: OAuthClientSlug, authorizationUrl: Schema.String, tokenUrl: Schema.String, - grant: Schema.Literals(["authorization_code", "client_credentials"]), + grant: Schema.Literals(["authorization_code", "client_credentials", "id_jag"]), clientId: Schema.String, clientSecret: Schema.String, resource: Schema.optional(Schema.NullOr(Schema.String)), @@ -110,7 +111,7 @@ const RegisterDynamicResponse = Schema.Struct({ const OAuthClientSummaryResponse = Schema.Struct({ owner: Owner, slug: OAuthClientSlug, - grant: Schema.Literals(["authorization_code", "client_credentials"]), + grant: Schema.Literals(["authorization_code", "client_credentials", "id_jag"]), authorizationUrl: Schema.String, tokenUrl: Schema.String, resource: Schema.optional(Schema.NullOr(Schema.String)), @@ -173,6 +174,19 @@ const StartPayload = Schema.Struct({ * name server-side instead of re-minting the existing row. */ newConnection: Schema.optional(Schema.Boolean), redirectUri: Schema.optional(Schema.NullOr(Schema.String)), + /** Enterprise-managed authorization inputs (MCP EMA profile). Required when + * the named client's grant is `id_jag`, ignored otherwise: the client's own + * id/secret authenticate at the MCP server's authorization server, while + * these name the SECOND registration at the enterprise identity provider and + * carry the identity assertion the user already holds from single sign-on. */ + enterprise: Schema.optional( + Schema.Struct({ + idpClient: OAuthClientSlug, + idpClientOwner: Owner, + subjectToken: Schema.String, + subjectTokenType: Schema.optional(SubjectTokenTypeSchema), + }), + ), }); const StartResponse = Schema.Union([ diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index 7f28ab58e7..faf5e44a21 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -195,6 +195,12 @@ export class CredentialResolutionError extends Schema.TaggedErrorClass => + Effect.gen(function* () { + if (provider.set) { + // OAuth is always single-input: the access token lives in the `token` + // item. Fall back to a deterministic id if the map is somehow empty. + const tokenItemId = + connectionItemIds(row)[PRIMARY_INPUT_VARIABLE] ?? + `connection:${row.owner}:${row.integration}:${row.name}:${PRIMARY_INPUT_VARIABLE}`; + yield* provider.set(ProviderItemId.make(tokenItemId), token.access_token); + if (token.refresh_token && row.refresh_item_id) { + yield* provider.set(ProviderItemId.make(row.refresh_item_id), token.refresh_token); + } + } + + const nextExpiresAt = + typeof token.expires_in === "number" ? Date.now() + token.expires_in * 1000 : null; + const set: Record = { + expires_at: nextExpiresAt, + updated_at: new Date(), + }; + if (token.scope !== undefined) set.oauth_scope = token.scope; + yield* core.updateMany("connection", { + where: (b: AnyCb) => + b.and( + byOwner(row.owner as Owner)(b), + b("integration", "=", String(row.integration)), + b("name", "=", String(row.name)), + ), + set, + }); + }); + + /** Re-mint an enterprise-managed access token: exchange the stored identity + * assertion for a fresh ID-JAG at the enterprise IdP, then redeem it at the + * MCP server's authorization server. Runs with no user interaction, which + * is the point of the profile. + * + * The grant profile is NOT re-discovered here. It was confirmed when the + * connection was made and persisted as part of its enterprise state; a + * fresh discovery round trip on every renewal could only ever restate it. */ + const performEnterpriseManagedRefresh = (input: { + readonly row: ConnectionRow; + readonly provider: CredentialProvider; + readonly client: RefreshClient; + readonly tokenUrl: string; + readonly scopes: readonly string[]; + readonly reauth: (message: string) => CredentialResolutionError; + }): Effect.Effect => + Effect.gen(function* () { + const { row, provider, client } = input; + const owner = row.owner as Owner; + const state = enterpriseManagedStateFrom(decodeJsonColumn(row.provider_state)); + if (state === null) { + return yield* input.reauth( + "This connection is missing its enterprise-managed authorization settings. Reconnect to continue.", + ); + } + const idpRow = yield* loadOAuthClientRow(state.idpClientOwner, state.idpClient); + if (!idpRow) { + return yield* input.reauth( + `The enterprise identity provider OAuth app "${state.idpClient}" is no longer registered.`, + ); + } + if (!row.refresh_item_id) { + return yield* input.reauth( + "No enterprise identity assertion is stored for this connection.", + ); + } + const subjectToken = yield* provider.get(ProviderItemId.make(row.refresh_item_id)); + if (!subjectToken) { + return yield* input.reauth( + "The stored enterprise identity assertion could not be resolved.", + ); + } + const idpClientSecret = idpRow.client_secret_item_id + ? ((yield* provider.get(ProviderItemId.make(String(idpRow.client_secret_item_id)))) ?? "") + : ""; + + const grant = yield* mintEnterpriseManagedAccessToken({ + idp: { + tokenUrl: String(idpRow.token_url), + clientId: String(idpRow.client_id), + clientSecret: idpClientSecret, + }, + resourceAuthorizationServer: { + tokenUrl: input.tokenUrl, + issuer: state.audience, + clientId: client.clientId, + clientSecret: client.clientSecret, + }, + subjectToken, + subjectTokenType: state.subjectTokenType, + resource: client.resource, + scopes: input.scopes, + endpointUrlPolicy: config.oauthEndpointUrlPolicy, + fetch: config.fetch, + }).pipe( + Effect.mapError((cause) => { + // A policy denial and a dead identity assertion are both definitive + // — neither retries into success — but they are DIFFERENT products: + // one is "your administrator has not allowed this", the other is + // "sign in again". Only the transport failure stays a StorageError + // so the next invoke retries it. + switch (cause._tag) { + case "EmaPolicyDenied": + return new CredentialResolutionError({ + owner, + integration: IntegrationSlug.make(row.integration), + name: ConnectionName.make(row.name), + message: cause.message, + reauthRequired: true, + blockedByAdmin: true, + oauthErrorCode: cause.error, + }); + case "EmaSubjectTokenRejected": + return new CredentialResolutionError({ + owner, + integration: IntegrationSlug.make(row.integration), + name: ConnectionName.make(row.name), + message: cause.message, + reauthRequired: true, + }); + case "EmaRedemptionRejected": + return new CredentialResolutionError({ + owner, + integration: IntegrationSlug.make(row.integration), + name: ConnectionName.make(row.name), + message: cause.message, + reauthRequired: cause.error === "invalid_grant", + ...(cause.error === undefined ? {} : { oauthErrorCode: cause.error }), + }); + case "EmaUpstreamUnavailable": + return new StorageError({ message: cause.message, cause }); + // The profile is confirmed at connect and never re-discovered on + // this path, so this constructor is unreachable here; surface it + // as a reconnect rather than pretending it cannot happen. + case "EmaGrantProfileUnsupported": + return new CredentialResolutionError({ + owner, + integration: IntegrationSlug.make(row.integration), + name: ConnectionName.make(row.name), + message: cause.message, + reauthRequired: true, + }); + } + }), + Effect.tapError((error) => + Predicate.isTagged(error, "CredentialResolutionError") && error.reauthRequired === true + ? // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field + markRefreshGrantDead(row, error.message) + : Effect.void, + ), + ); + + // Draft §4.4.3: the Resource Authorization Server SHOULD NOT issue a + // refresh token here. Drop one that arrives anyway — persisting it + // would overwrite the identity assertion that shares that slot, and the + // ID-JAG chain is already the renewal path. + const { refresh_token: _unused, ...token } = grant.token; + return { + ...token, + ...(grant.scope === null ? {} : { scope: grant.scope }), + } satisfies OAuth2TokenResponse; + }); + // Perform the actual refresh-token grant and persist the rotated material. const performTokenRefresh = ( row: ConnectionRow, @@ -1913,6 +2093,23 @@ export const createExecutor = = { - expires_at: nextExpiresAt, - updated_at: new Date(), - }; - if (token.scope !== undefined) set.oauth_scope = token.scope; - yield* core.updateMany("connection", { - where: (b: AnyCb) => - b.and( - byOwner(owner)(b), - b("integration", "=", String(row.integration)), - b("name", "=", String(row.name)), - ), - set, - }); - + yield* persistRefreshedToken(row, provider, token); return token.access_token; }).pipe( // The refresh path was previously invisible to telemetry: no span, no @@ -2987,6 +3156,20 @@ export const createExecutor = 0 - ? { missingOAuthScopes: input.missingOAuthScopes } - : null, + provider_state: providerState, // A re-mint replaces the grant, so any persisted verdict describes // a credential that no longer exists. Clear it rather than let a // pre-reconnect "expired" outlive the reconnect; the next health @@ -3045,10 +3225,7 @@ export const createExecutor = 0 - ? { missingOAuthScopes: input.missingOAuthScopes } - : null, + provider_state: providerState, created_at: now, updated_at: now, }); @@ -3082,10 +3259,7 @@ export const createExecutor = 0 - ? { missingOAuthScopes: input.missingOAuthScopes } - : null, + provider_state: providerState, created_at: now, updated_at: now, } as ConnectionRow); diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index c56e3a236a..2f874e1cfb 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -292,6 +292,11 @@ export { OAuthRegisterDynamicError, OAuthSessionNotFoundError, FIRST_PARTY_OAUTH_CLIENT_PREFIX, + SUBJECT_TOKEN_TYPES, + SubjectTokenTypeSchema, + DEFAULT_SUBJECT_TOKEN_TYPE, + type SubjectTokenType, + type EnterpriseManagedStartInput, firstPartyOAuthClientSlug, isFirstPartyOAuthClientSlug, type FirstPartyOAuthClientConfig, diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 8ec30c679a..10eab623ed 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -31,6 +31,7 @@ import { ProviderItemId, } from "./ids"; import { + DEFAULT_SUBJECT_TOKEN_TYPE, OAuthCompleteError, OAuthProbeError, OAuthRegisterDynamicError, @@ -41,6 +42,7 @@ import { isFirstPartyOAuthClientSlug, type ConnectResult, type CreateOAuthClientInput, + type EnterpriseManagedStartInput, type FirstPartyOAuthClientConfig, type OAuthClientOrigin, type OAuthClientSummary, @@ -61,6 +63,11 @@ import { registerDynamicClient as registerDynamicClientDcr, type OAuthAuthorizationServerMetadata, } from "./oauth-discovery"; +import { + runEnterpriseManagedAuthorization, + type EnterpriseManagedConnectionState, + type EnterpriseManagedGrant, +} from "./oauth-ema"; import { assertSupportedOAuthEndpointUrl, buildAuthorizationUrl, @@ -102,6 +109,10 @@ export interface MintOAuthConnectionInput { readonly expiresAt: number | null; readonly oauthScope: string | null; readonly missingOAuthScopes?: readonly string[]; + /** Enterprise-managed authorization wiring, for connections minted through + * the ID-JAG grant profile. Persisted on the connection so token renewal can + * re-run the exchange without the user. Omitted for every other grant. */ + readonly enterpriseManaged?: EnterpriseManagedConnectionState; /** Per-connection override for the token endpoint, persisted only when the * code was redeemed at a region other than the client's configured token * host (Datadog multi-site). Null means refresh uses the client's token URL. */ @@ -309,7 +320,9 @@ const clientOwnerFromPayload = (payload: unknown): Owner | null => { * `authorization_code`; an unknown grant means a corrupt row and callers that * drive token exchange (`loadClient`) must fail loudly rather than guessing. */ const parseGrant = (grant: unknown): OAuthGrant | null => - grant === "client_credentials" || grant === "authorization_code" ? grant : null; + grant === "client_credentials" || grant === "authorization_code" || grant === "id_jag" + ? grant + : null; const canonicalDcrIssuer = ( issuer: string | null | undefined, @@ -633,6 +646,42 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }), ); + /** The RFC 8414 metadata of the authorization server that protects `resource`. + * Enterprise-managed authorization needs two facts that live ONLY here: the + * issuer identifier the ID-JAG must name as its audience, and whether the + * server implements the ID-JAG grant profile at all. Same discovery order as + * scope discovery — the protected resource names its authorization servers; + * we never probe an arbitrary URL. */ + const discoverResourceAuthorizationServer = ( + resource: string | null, + ): Effect.Effect => + Effect.gen(function* () { + if (resource == null) { + return yield* new OAuthDiscoveryError({ + message: + "Cannot discover the authorization server: the OAuth app has no resource configured", + }); + } + const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy, httpClientLayer }; + const protectedResource = yield* discoverProtectedResourceMetadata( + resource, + discoveryOptions, + ); + const issuers = protectedResource?.metadata.authorization_servers ?? []; + for (const issuer of issuers.slice(0, MAX_DISCOVERY_AUTH_SERVERS)) { + const authServer = yield* discoverAuthorizationServerMetadata( + issuer, + discoveryOptions, + ).pipe(Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null))); + if (authServer) return authServer.metadata; + } + return yield* new OAuthDiscoveryError({ + message: `No authorization-server metadata found for ${resource}${ + issuers.length > 0 ? ` (tried: ${issuers.join(", ")})` : "" + }`, + }); + }); + // ----------------------------------------------------------------------- // createClient — write the oauth_client row. // ----------------------------------------------------------------------- @@ -1288,6 +1337,97 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { return { status: "connected", connection } as const; } + // Enterprise-managed authorization (draft §4): no browser, no per-server + // consent — exchange the identity assertion the user already holds. Only + // an authorization server that does NOT advertise the grant profile falls + // through to the interactive flow below; an IdP refusal is an enterprise + // policy decision and stops here, because offering the interactive flow + // instead would let the user route straight around it. + if (client.grant === "id_jag") { + const enterprise = input.enterprise; + if (enterprise === undefined) { + return yield* new OAuthStartError({ + message: + "This OAuth app uses enterprise-managed authorization, which requires an enterprise identity provider and an identity assertion on the connect request.", + }); + } + const idpClient = yield* loadClient(enterprise.idpClientOwner, enterprise.idpClient); + if (!idpClient) { + return yield* new OAuthStartError({ + message: `Enterprise identity provider OAuth client not found: ${enterprise.idpClient}`, + }); + } + const metadata = yield* discoverResourceAuthorizationServer(client.resource).pipe( + Effect.mapError( + (cause) => + new OAuthStartError({ + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuthDiscoveryError carries a typed `message` field + message: `Failed to discover the MCP server's authorization server: ${cause.message}`, + }), + ), + ); + const enterpriseGrant = yield* runEnterpriseManagedAuthorization({ + authorizationServerMetadata: metadata, + idp: { + tokenUrl: idpClient.tokenUrl, + clientId: idpClient.clientId, + clientSecret: idpClient.clientSecret, + }, + resourceAuthorizationServer: { + clientId: client.clientId, + clientSecret: client.clientSecret, + }, + subjectToken: enterprise.subjectToken, + subjectTokenType: enterprise.subjectTokenType ?? DEFAULT_SUBJECT_TOKEN_TYPE, + resource: client.resource, + scopes: requestedScopes, + endpointUrlPolicy: deps.endpointUrlPolicy, + fetch, + }).pipe( + Effect.provide(httpClientLayer), + Effect.map((grant) => ({ supported: true as const, grant })), + // Only the unsupported-profile failure is recoverable; every other + // tag reaches the caller as a start error with its own wording. + Effect.catchTag("EmaGrantProfileUnsupported", () => + Effect.succeed({ supported: false as const }), + ), + Effect.mapError( + (cause) => + new OAuthStartError({ + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: every EMA error carries a typed `message` getter + message: cause.message, + }), + ), + ); + if (enterpriseGrant.supported) { + const connection = yield* mintEnterpriseManagedConnection( + { ...input, name }, + client, + input.clientOwner, + enterpriseGrant.grant, + enterprise, + { + idpClient: String(enterprise.idpClient), + idpClientOwner: enterprise.idpClientOwner, + audience: metadata.issuer, + subjectTokenType: enterprise.subjectTokenType ?? DEFAULT_SUBJECT_TOKEN_TYPE, + }, + ).pipe( + Effect.mapError( + (cause) => + new OAuthStartError({ + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: StorageFailure carries a typed `message` field + message: `Failed to mint OAuth connection: ${cause.message}`, + }), + ), + ); + return { status: "connected", connection } as const; + } + yield* Effect.annotateCurrentSpan({ + "executor.oauth.enterprise_managed_fallback": true, + }); + } + // authorization_code requires our callback to receive the code — fail // loudly if the executor was constructed without a redirectUri rather // than persisting a session pointed at a wrong localhost callback. @@ -1632,6 +1772,61 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); }); + /** Mint a connection from an enterprise-managed grant. Distinct from + * `mintFromToken` because the material persisted is different: there is no + * refresh token (draft §4.4.3), and the identity assertion takes the refresh + * slot — it is exactly the credential that lets renewal run without the + * user, which is what that slot means. */ + const mintEnterpriseManagedConnection = ( + target: { + readonly owner: Owner; + readonly name: ConnectionName; + readonly integration: IntegrationSlug; + readonly template: AuthTemplateSlug; + readonly identityLabel?: string | null; + }, + client: LoadedOAuthClient, + clientOwner: Owner, + grant: EnterpriseManagedGrant, + enterprise: EnterpriseManagedStartInput, + enterpriseState: EnterpriseManagedConnectionState, + ): Effect.Effect => + Effect.gen(function* () { + const provider = deps.defaultWritableProvider(); + if (!provider || !provider.set) { + return yield* new StorageError({ + message: + "No default writable credential provider is registered to store the OAuth access token.", + cause: undefined, + }); + } + const itemId = accessItemId(target.owner, target.integration, target.name); + yield* provider.set(ProviderItemId.make(itemId), grant.token.access_token); + const subjectTokenItemId = refreshItemIdFor(itemId); + yield* provider.set(ProviderItemId.make(subjectTokenItemId), enterprise.subjectToken); + + yield* Effect.annotateCurrentSpan({ + "executor.oauth.has_advertised_expiry": typeof grant.token.expires_in === "number", + "executor.oauth.enterprise_managed": true, + }); + return yield* deps.mintOAuthConnection({ + owner: target.owner, + name: target.name, + integration: target.integration, + template: target.template, + identityLabel: target.identityLabel ?? null, + derivedIdentityLabel: grant.token.idTokenIdentityLabel ?? null, + provider: String(provider.key), + itemId, + oauthClient: OAuthClientSlug.make(client.slug), + oauthClientOwner: clientOwner, + refreshItemId: subjectTokenItemId, + expiresAt: expiresAtFrom(grant.token), + oauthScope: grant.scope, + enterpriseManaged: enterpriseState, + }); + }); + const deleteSession = (state: OAuthState): Effect.Effect => deps.fuma .use("oauth_session.delete", (db) => diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 4a13eac961..b436f2a672 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -147,6 +147,11 @@ export { FIRST_PARTY_OAUTH_CLIENT_PREFIX, firstPartyOAuthClientSlug, isFirstPartyOAuthClientSlug, + SUBJECT_TOKEN_TYPES, + SubjectTokenTypeSchema, + DEFAULT_SUBJECT_TOKEN_TYPE, + type SubjectTokenType, + type EnterpriseManagedStartInput, type FirstPartyOAuthClientConfig, type OAuthGrant, type OAuthAuthentication, From 888b2c3e57409975c8ab12269fd31d68f6a595db Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:46:42 -0700 Subject: [PATCH 03/11] Add ID-JAG protocol conformance fixtures and tests --- packages/core/sdk/src/oauth-ema.test.ts | 512 ++++++++++++++++++ .../sdk/src/testing/id-jag-test-support.ts | 245 +++++++++ .../core/sdk/src/testing/oauth-test-server.ts | 274 +++++++++- 3 files changed, 1030 insertions(+), 1 deletion(-) create mode 100644 packages/core/sdk/src/oauth-ema.test.ts create mode 100644 packages/core/sdk/src/testing/id-jag-test-support.ts diff --git a/packages/core/sdk/src/oauth-ema.test.ts b/packages/core/sdk/src/oauth-ema.test.ts new file mode 100644 index 0000000000..56453f1a7e --- /dev/null +++ b/packages/core/sdk/src/oauth-ema.test.ts @@ -0,0 +1,512 @@ +// --------------------------------------------------------------------------- +// Protocol conformance for MCP Enterprise-Managed Authorization +// (draft-ietf-oauth-identity-assertion-authz-grant-04). +// +// The fixtures are generic protocol servers, not fakes of a named product: one +// plays the enterprise IdP Authorization Server, one plays the MCP server's +// Resource Authorization Server, and they are wired to each other exactly as +// the draft describes (RFC 8414 metadata → `jwks_uri` → RS256 verification). +// They are STRICTER than deployed servers on purpose — a lenient fixture would +// let a client bug pass here and fail in production. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Ref, Schema } from "effect"; +import { HttpServerResponse } from "effect/unstable/http"; + +import { supportsIdJagGrantProfile } from "./oauth-discovery"; +import { + EmaGrantProfileUnsupported, + mintEnterpriseManagedAccessToken, + permitsInteractiveFallback, + runEnterpriseManagedAuthorization, + type EnterpriseManagedAuthorizationError, +} from "./oauth-ema"; +import { exchangeSubjectTokenForIdJag, redeemIdJagAssertion } from "./oauth-helpers"; +import { serveOAuthTestServer, serveTestHttpApp, type OAuthTestServerShape } from "./testing"; + +const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" as const; +const ID_JAG_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag"; + +const CLIENT_AT_IDP = "mcp-client-at-idp"; +const CLIENT_AT_RESOURCE = "mcp-client-at-resource"; + +interface EnterpriseFixture { + readonly idp: OAuthTestServerShape; + readonly resource: OAuthTestServerShape; + readonly subjectToken: string; +} + +/** Stand up the two authorization servers of the profile plus a signed-in user + * at the IdP, and return the identity assertion that single sign-on produced. + * The IdP maps the client's IdP-side id to its DIFFERENT resource-side id + * (draft §5), so every test here exercises the cross-domain client_id handling + * rather than the degenerate same-id case. */ +const enterpriseFixture = ( + overrides: { + readonly idp?: Parameters[0]; + readonly resourceAdvertisesProfile?: boolean; + readonly resourceGrantableScopes?: readonly string[]; + } = {}, +): Effect.Effect => + Effect.gen(function* () { + const idp = yield* serveOAuthTestServer({ + clients: { [CLIENT_AT_IDP]: null }, + scopes: ["mcp.read", "mcp.write"], + ...overrides.idp, + enterpriseIdp: { + resourceClientIds: { [CLIENT_AT_IDP]: CLIENT_AT_RESOURCE }, + ...overrides.idp?.enterpriseIdp, + }, + }); + const resource = yield* serveOAuthTestServer({ + clients: { [CLIENT_AT_RESOURCE]: null }, + scopes: ["mcp.read", "mcp.write"], + ...(overrides.resourceAdvertisesProfile === false + ? {} + : { + enterpriseResourceServer: { + trustedIdpIssuer: idp.issuerUrl, + ...(overrides.resourceGrantableScopes === undefined + ? {} + : { grantableScopes: overrides.resourceGrantableScopes }), + }, + }), + }); + const session = yield* idp.completeAuthorizationCodeTokenFlow({ + clientId: CLIENT_AT_IDP, + clientSecret: "", + scopes: ["mcp.read", "mcp.write"], + }); + return { idp, resource, subjectToken: session.accessToken }; + }) as Effect.Effect; + +const chainInput = (fixture: EnterpriseFixture, scopes: readonly string[]) => ({ + idp: { tokenUrl: fixture.idp.tokenEndpoint, clientId: CLIENT_AT_IDP }, + resourceAuthorizationServer: { + tokenUrl: fixture.resource.tokenEndpoint, + issuer: fixture.resource.issuerUrl, + clientId: CLIENT_AT_RESOURCE, + }, + subjectToken: fixture.subjectToken, + subjectTokenType: ACCESS_TOKEN_TYPE, + scopes, +}); + +/** Fetch the resource server's RFC 8414 metadata the way the connect path does, + * so the profile-detection assertions run against a real document. */ +const resourceMetadata = (fixture: EnterpriseFixture) => + Effect.gen(function* () { + const response = yield* Effect.promise(() => + globalThis.fetch(`${fixture.resource.issuerUrl}/.well-known/oauth-authorization-server`), + ); + return yield* Effect.promise(() => response.json() as Promise); + }); + +const AuthorizationServerMetadata = Schema.Struct({ + issuer: Schema.String, + authorization_endpoint: Schema.String, + token_endpoint: Schema.String, + grant_types_supported: Schema.optional(Schema.Array(Schema.String)), + authorization_grant_profiles_supported: Schema.optional(Schema.Array(Schema.String)), +}); +const decodeMetadata = Schema.decodeUnknownSync(AuthorizationServerMetadata); + +describe("enterprise-managed authorization: the ID-JAG chain", () => { + it.effect("mints an MCP access token from an enterprise identity assertion", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* enterpriseFixture(); + const metadata = decodeMetadata(yield* resourceMetadata(fixture)); + + expect( + supportsIdJagGrantProfile(metadata), + "the resource authorization server advertises the id-jag grant profile", + ).toBe(true); + expect( + metadata.grant_types_supported, + "draft §7.2: advertising the profile requires advertising jwt-bearer too", + ).toContain("urn:ietf:params:oauth:grant-type:jwt-bearer"); + + const grant = yield* runEnterpriseManagedAuthorization({ + ...chainInput(fixture, ["mcp.read", "mcp.write"]), + authorizationServerMetadata: metadata, + resourceAuthorizationServer: { clientId: CLIENT_AT_RESOURCE }, + }); + + expect(grant.scope, "the granted scope survives the whole chain").toBe( + "mcp.read mcp.write", + ); + expect( + yield* fixture.resource.acceptsAccessToken(grant.token.access_token), + "the MCP server's authorization server issued the access token", + ).toBe(true); + expect( + grant.token.refresh_token, + "draft §4.4.3: redeeming an ID-JAG issues no refresh token", + ).toBeUndefined(); + expect( + grant.token.token_type, + "the redemption yields an ordinary bearer access token (case-normalised by the OAuth library)", + ).toBe("bearer"); + }), + ), + ); + + it.effect("re-runs the exchange on every mint rather than reusing an assertion", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* enterpriseFixture(); + const first = yield* mintEnterpriseManagedAccessToken(chainInput(fixture, ["mcp.read"])); + const second = yield* mintEnterpriseManagedAccessToken(chainInput(fixture, ["mcp.read"])); + + expect( + second.token.access_token, + "an expired access token is replaced, never re-issued", + ).not.toBe(first.token.access_token); + const exchanges = (yield* fixture.idp.requests).filter( + (entry) => entry.path === "/token" && entry.body.includes("token-exchange"), + ); + expect( + exchanges.length, + "each renewal goes back to the IdP so its policy is re-evaluated", + ).toBe(2); + }), + ), + ); + + it.effect("narrows the access token to the scopes the resource server grants", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* enterpriseFixture({ resourceGrantableScopes: ["mcp.read"] }); + const grant = yield* mintEnterpriseManagedAccessToken( + chainInput(fixture, ["mcp.read", "mcp.write"]), + ); + expect( + grant.scope, + "the resource authorization server may grant a subset of the assertion's scope", + ).toBe("mcp.read"); + }), + ), + ); + + it.effect("carries the IdP's narrowed scope forward instead of re-requesting more", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* enterpriseFixture({ + idp: { enterpriseIdp: { grantScope: () => "mcp.read" } }, + }); + const grant = yield* mintEnterpriseManagedAccessToken( + chainInput(fixture, ["mcp.read", "mcp.write"]), + ); + expect(grant.scope, "enterprise policy narrowed the grant at the IdP").toBe("mcp.read"); + const redemptions = (yield* fixture.resource.requests).filter( + (entry) => entry.path === "/token", + ); + expect( + redemptions.at(-1)?.body, + "the redemption must not ask for more than the IdP granted", + ).toContain("scope=mcp.read"); + expect(redemptions.at(-1)?.body).not.toContain("mcp.write"); + }), + ), + ); +}); + +describe("enterprise-managed authorization: failure taxonomy", () => { + it.effect("reports an unadvertised grant profile as the one fallback-safe failure", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* enterpriseFixture({ resourceAdvertisesProfile: false }); + const metadata = decodeMetadata(yield* resourceMetadata(fixture)); + expect(supportsIdJagGrantProfile(metadata)).toBe(false); + + const error = yield* runEnterpriseManagedAuthorization({ + ...chainInput(fixture, ["mcp.read"]), + authorizationServerMetadata: metadata, + resourceAuthorizationServer: { clientId: CLIENT_AT_RESOURCE }, + }).pipe(Effect.flip); + + expect(error._tag).toBe("EmaGrantProfileUnsupported"); + expect( + permitsInteractiveFallback(error), + "a server that does not implement the profile gets the ordinary OAuth flow", + ).toBe(true); + expect( + (yield* fixture.idp.requests).some((entry) => entry.body.includes("token-exchange")), + "detection happens before any token is exchanged", + ).toBe(false); + }), + ), + ); + + it.effect("reports an IdP policy refusal as blocked-by-admin, never as a fallback", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* enterpriseFixture({ + idp: { + enterpriseIdp: { + denyExchangeWith: { + error: "unauthorized_client", + errorDescription: "Policy does not permit this client to access the target server.", + }, + }, + }, + }); + const metadata = decodeMetadata(yield* resourceMetadata(fixture)); + const error: EnterpriseManagedAuthorizationError = yield* runEnterpriseManagedAuthorization( + { + ...chainInput(fixture, ["mcp.read"]), + authorizationServerMetadata: metadata, + resourceAuthorizationServer: { clientId: CLIENT_AT_RESOURCE }, + }, + ).pipe(Effect.flip); + + expect(error._tag).toBe("EmaPolicyDenied"); + if (error._tag !== "EmaPolicyDenied") return; + expect(error.error).toBe("unauthorized_client"); + expect( + permitsInteractiveFallback(error), + "offering interactive OAuth here would route the user around enterprise policy", + ).toBe(false); + expect( + (yield* fixture.resource.requests).some((entry) => entry.path === "/token"), + "a denied exchange never reaches the resource authorization server", + ).toBe(false); + }), + ), + ); + + it.effect("reports a dead identity assertion as needing single sign-on again", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* enterpriseFixture(); + yield* fixture.idp.revokeAccessToken(fixture.subjectToken); + + const error = yield* mintEnterpriseManagedAccessToken( + chainInput(fixture, ["mcp.read"]), + ).pipe(Effect.flip); + + expect(error._tag).toBe("EmaSubjectTokenRejected"); + expect(permitsInteractiveFallback(error)).toBe(false); + }), + ), + ); + + it.effect("rejects an ID-JAG minted for a different authorization server", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* enterpriseFixture(); + const otherResource = yield* serveOAuthTestServer({ + clients: { [CLIENT_AT_RESOURCE]: null }, + enterpriseResourceServer: { trustedIdpIssuer: fixture.idp.issuerUrl }, + }); + + // Ask the IdP for an assertion whose `aud` names the OTHER server, then + // present it here. The signature is genuine and the client is the right + // one; only the audience is wrong, which is the whole confused-deputy + // attack the `aud` check exists to stop. + const misdirected = yield* exchangeSubjectTokenForIdJag({ + tokenUrl: fixture.idp.tokenEndpoint, + clientId: CLIENT_AT_IDP, + subjectToken: fixture.subjectToken, + subjectTokenType: ACCESS_TOKEN_TYPE, + audience: otherResource.issuerUrl, + }); + + const error = yield* redeemIdJagAssertion({ + tokenUrl: fixture.resource.tokenEndpoint, + clientId: CLIENT_AT_RESOURCE, + assertion: misdirected.assertion, + }).pipe(Effect.flip); + + expect(error.error).toBe("invalid_grant"); + expect(error.message).toContain("does not name this authorization server"); + }), + ), + ); + + it.effect("rejects an assertion whose header typ is not oauth-id-jag+jwt", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* enterpriseFixture({ + idp: { enterpriseIdp: { assertionTyp: "JWT" } }, + }); + const error = yield* mintEnterpriseManagedAccessToken( + chainInput(fixture, ["mcp.read"]), + ).pipe(Effect.flip); + + expect(error._tag).toBe("EmaRedemptionRejected"); + expect(error.message).toContain("typ must be oauth-id-jag+jwt"); + }), + ), + ); + + it.effect("rejects an expired ID-JAG", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* enterpriseFixture({ + idp: { enterpriseIdp: { idJagExpiresInSeconds: -1 } }, + }); + const error = yield* mintEnterpriseManagedAccessToken( + chainInput(fixture, ["mcp.read"]), + ).pipe(Effect.flip); + + expect(error._tag).toBe("EmaRedemptionRejected"); + expect(error.message).toContain("has expired"); + }), + ), + ); + + it.effect("rejects an ID-JAG whose client_id claim names a different client", () => + Effect.scoped( + Effect.gen(function* () { + // The IdP maps this client to a resource-side id nobody registered, so the + // assertion's `client_id` cannot match whoever authenticates the + // redemption. draft §4.4.1 requires the server to refuse. + const fixture = yield* enterpriseFixture({ + idp: { enterpriseIdp: { resourceClientIds: { [CLIENT_AT_IDP]: "some-other-client" } } }, + }); + const error = yield* mintEnterpriseManagedAccessToken( + chainInput(fixture, ["mcp.read"]), + ).pipe(Effect.flip); + + expect(error._tag).toBe("EmaRedemptionRejected"); + expect(error.message).toContain("does not match the authenticated client"); + }), + ), + ); + + it.effect("refuses to treat an ID-JAG as a bearer token for the MCP endpoint", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* enterpriseFixture(); + const grant = yield* exchangeSubjectTokenForIdJag({ + tokenUrl: fixture.idp.tokenEndpoint, + clientId: CLIENT_AT_IDP, + subjectToken: fixture.subjectToken, + subjectTokenType: ACCESS_TOKEN_TYPE, + audience: fixture.resource.issuerUrl, + }); + + const response = yield* Effect.promise(() => + globalThis.fetch(fixture.resource.mcpResourceUrl, { + method: "POST", + headers: { authorization: `Bearer ${grant.assertion}` }, + }), + ); + expect( + response.status, + "an authorization grant is not an access token and the resource must say so", + ).toBe(401); + }), + ), + ); +}); + +// --------------------------------------------------------------------------- +// Token-exchange response contract (draft §4.3.4). These need a server that +// answers WRONGLY, which the conformant fixture above will never do. +// --------------------------------------------------------------------------- + +const serveTokenEndpoint = (body: Readonly>) => + Effect.gen(function* () { + const requests = yield* Ref.make([]); + const server = yield* serveTestHttpApp((request) => + Effect.gen(function* () { + const text = yield* request.text.pipe(Effect.catch(() => Effect.succeed(""))); + yield* Ref.update(requests, (all) => [...all, text]); + return HttpServerResponse.jsonUnsafe(body, { status: 200 }); + }), + ); + return { tokenUrl: `${server.baseUrl}/token`, requests: Ref.get(requests) }; + }); + +describe("enterprise-managed authorization: token exchange response contract", () => { + it.effect("rejects a response whose issued_token_type is not an ID-JAG", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveTokenEndpoint({ + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + access_token: "not-an-assertion", + token_type: "N_A", + }); + const error = yield* exchangeSubjectTokenForIdJag({ + tokenUrl: server.tokenUrl, + clientId: CLIENT_AT_IDP, + subjectToken: "assertion", + subjectTokenType: ACCESS_TOKEN_TYPE, + audience: "https://resource.example", + }).pipe(Effect.flip); + expect(error.message).toContain("issued_token_type"); + }), + ), + ); + + it.effect("rejects a response whose token_type is not the N_A sentinel", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveTokenEndpoint({ + issued_token_type: ID_JAG_TOKEN_TYPE, + access_token: "assertion", + token_type: "Bearer", + }); + const error = yield* exchangeSubjectTokenForIdJag({ + tokenUrl: server.tokenUrl, + clientId: CLIENT_AT_IDP, + subjectToken: "assertion", + subjectTokenType: ACCESS_TOKEN_TYPE, + audience: "https://resource.example", + }).pipe(Effect.flip); + expect( + error.message, + "a Bearer token here would be a live credential the client would treat as a grant", + ).toContain("token_type"); + }), + ), + ); + + it.effect("sends the draft §4.3 parameters verbatim", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveTokenEndpoint({ + issued_token_type: ID_JAG_TOKEN_TYPE, + access_token: "assertion", + token_type: "N_A", + }); + yield* exchangeSubjectTokenForIdJag({ + tokenUrl: server.tokenUrl, + clientId: CLIENT_AT_IDP, + subjectToken: "the-assertion", + subjectTokenType: ACCESS_TOKEN_TYPE, + audience: "https://auth.example/", + resource: "https://mcp.example/mcp", + scopes: ["mcp.read"], + }); + const sent = new URLSearchParams((yield* server.requests)[0] ?? ""); + expect(sent.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:token-exchange"); + expect(sent.get("requested_token_type")).toBe(ID_JAG_TOKEN_TYPE); + expect(sent.get("audience"), "EMA §4: the audience is the resource AS issuer").toBe( + "https://auth.example/", + ); + expect(sent.get("resource"), "EMA §4: the resource is the MCP server's identifier").toBe( + "https://mcp.example/mcp", + ); + expect(sent.get("subject_token")).toBe("the-assertion"); + expect(sent.get("subject_token_type")).toBe(ACCESS_TOKEN_TYPE); + expect(sent.get("scope")).toBe("mcp.read"); + }), + ), + ); +}); + +describe("EmaGrantProfileUnsupported", () => { + it("names the profile the server failed to advertise", () => { + const error = new EmaGrantProfileUnsupported({ + issuer: "https://auth.example", + advertised: ["urn:example:other"], + }); + expect(error.message).toContain("urn:ietf:params:oauth:grant-profile:id-jag"); + expect(error.message).toContain("urn:example:other"); + }); +}); diff --git a/packages/core/sdk/src/testing/id-jag-test-support.ts b/packages/core/sdk/src/testing/id-jag-test-support.ts new file mode 100644 index 0000000000..5f1109cec9 --- /dev/null +++ b/packages/core/sdk/src/testing/id-jag-test-support.ts @@ -0,0 +1,245 @@ +// --------------------------------------------------------------------------- +// Identity Assertion JWT Authorization Grant (ID-JAG) support for the OAuth +// test server — a generic protocol-conformance fixture for +// draft-ietf-oauth-identity-assertion-authz-grant-04, not a fake of any named +// product. +// +// The fixture is deliberately STRICTER than any deployed server we know of. A +// lenient fixture is worse than none: it lets a client bug (an unsigned +// assertion, an audience meant for someone else, a `typ` nobody checked) pass +// the suite and fail in production. Every MUST in §4.4.1 is enforced here. +// +// Signing is real RS256 over a per-server RSA keypair, published as an RFC 7517 +// JWKS. The Resource Authorization Server half fetches that JWKS from the IdP's +// advertised metadata exactly as a deployed server would, so a forged or +// tampered assertion cannot pass. +// --------------------------------------------------------------------------- + +import { + createPublicKey, + createSign, + createVerify, + generateKeyPairSync, + randomUUID, + type KeyObject, +} from "node:crypto"; +import { Option, Schema } from "effect"; + +/** draft §3.1 — the media type an ID-JAG MUST carry in its JWT header. */ +export const ID_JAG_HEADER_TYP = "oauth-id-jag+jwt"; + +/** draft §7.2 grant profile identifier. */ +export const ID_JAG_GRANT_PROFILE_URN = "urn:ietf:params:oauth:grant-profile:id-jag"; + +export const ID_JAG_TOKEN_TYPE_URN = "urn:ietf:params:oauth:token-type:id-jag"; +export const TOKEN_EXCHANGE_GRANT_TYPE_URN = "urn:ietf:params:oauth:grant-type:token-exchange"; +export const JWT_BEARER_GRANT_TYPE_URN = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + +export interface IdJagSigningKey { + readonly keyId: string; + readonly privateKey: KeyObject; + readonly publicKey: KeyObject; +} + +export const createIdJagSigningKey = (): IdJagSigningKey => { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return { keyId: `idjag-${randomUUID()}`, privateKey, publicKey }; +}; + +/** The RFC 7517 JWKS document a fixture IdP publishes at its `jwks_uri`. */ +export const jwksDocumentFor = (key: IdJagSigningKey): Readonly> => ({ + keys: [ + { + ...(key.publicKey.export({ format: "jwk" }) as Record), + kid: key.keyId, + use: "sig", + alg: "RS256", + }, + ], +}); + +const base64UrlJson = (value: unknown): string => + Buffer.from(JSON.stringify(value)).toString("base64url"); + +export interface IdJagClaims { + readonly iss: string; + readonly sub: string; + readonly aud: string; + readonly client_id: string; + readonly exp: number; + readonly iat: number; + readonly jti: string; + readonly resource?: string; + readonly scope?: string; + readonly email?: string; +} + +/** Mint a signed ID-JAG. `typ` is a parameter rather than a constant so a test + * can prove the Resource Authorization Server rejects a wrongly-typed JWT. */ +export const signIdJag = (input: { + readonly key: IdJagSigningKey; + readonly claims: IdJagClaims; + readonly typ?: string; +}): string => { + const header = base64UrlJson({ + alg: "RS256", + typ: input.typ ?? ID_JAG_HEADER_TYP, + kid: input.key.keyId, + }); + const payload = base64UrlJson(input.claims); + const signingInput = `${header}.${payload}`; + const signature = createSign("RSA-SHA256") + .update(signingInput) + .sign(input.key.privateKey) + .toString("base64url"); + return `${signingInput}.${signature}`; +}; + +// --------------------------------------------------------------------------- +// Verification (the Resource Authorization Server half of §4.4.1) +// --------------------------------------------------------------------------- + +const JwtHeaderSchema = Schema.Struct({ + alg: Schema.String, + typ: Schema.optional(Schema.String), + kid: Schema.optional(Schema.String), +}); + +const JwtClaimsSchema = Schema.Struct({ + iss: Schema.String, + sub: Schema.String, + aud: Schema.Union([Schema.String, Schema.Array(Schema.String)]), + client_id: Schema.String, + exp: Schema.Number, + iat: Schema.Number, + jti: Schema.String, + resource: Schema.optional(Schema.String), + scope: Schema.optional(Schema.String), + email: Schema.optional(Schema.String), +}); + +const decodeHeader = Schema.decodeUnknownOption(Schema.fromJsonString(JwtHeaderSchema)); +const decodeClaims = Schema.decodeUnknownOption(Schema.fromJsonString(JwtClaimsSchema)); + +/** Only the RSA public parameters this fixture signs with. Decoding the key set + * into a precise shape (rather than probing an untyped record) is what lets the + * verification below hand `node:crypto` a key it already knows is well formed. */ +const RsaPublicJwkSchema = Schema.Struct({ + kty: Schema.Literal("RSA"), + n: Schema.String, + e: Schema.String, + kid: Schema.optional(Schema.String), +}); + +const JwksSchema = Schema.Struct({ keys: Schema.Array(RsaPublicJwkSchema) }); + +const decodeJwks = Schema.decodeUnknownOption(JwksSchema); + +const segment = (token: string, index: number): string | null => { + const parts = token.split("."); + return parts.length === 3 ? (parts[index] ?? null) : null; +}; + +const decodedSegment = (token: string, index: number): string | null => { + const raw = segment(token, index); + if (raw === null) return null; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: an untrusted assertion segment that is not base64url is simply not a JWT + try { + return Buffer.from(raw, "base64url").toString("utf8"); + } catch { + return null; + } +}; + +export type IdJagVerification = + | { readonly ok: true; readonly claims: typeof JwtClaimsSchema.Type } + | { readonly ok: false; readonly detail: string }; + +const rejected = (detail: string): IdJagVerification => ({ ok: false, detail }); + +/** Verify an ID-JAG the way draft §4.4.1 requires a Resource Authorization + * Server to. Every check below is a MUST in the draft, and each one is exactly + * what stops a class of client bug from shipping: + * + * - `typ` guards against a plain ID token being replayed as a grant; + * - the signature check against the IdP's published JWKS guards against a + * forged or tampered assertion; + * - `aud` equal to THIS server's issuer is the confused-deputy defence: an + * assertion minted for a different authorization server must not work + * here, no matter who presents it; + * - `client_id` equal to the authenticated client preserves the OAuth client + * binding across the two trust domains; + * - `exp` keeps a stale assertion from being an unbounded credential. + */ +export const verifyIdJag = (input: { + readonly assertion: string; + readonly trustedIssuer: string; + readonly jwks: unknown; + /** This server's own RFC 8414 issuer identifier. */ + readonly audience: string; + /** The client id the token request authenticated as. */ + readonly authenticatedClientId: string; + readonly nowSeconds?: number; +}): IdJagVerification => { + const headerJson = decodedSegment(input.assertion, 0); + const claimsJson = decodedSegment(input.assertion, 1); + const signature = segment(input.assertion, 2); + if (headerJson === null || claimsJson === null || signature === null) { + return rejected("assertion is not a three-part JWT"); + } + const header = decodeHeader(headerJson); + if (Option.isNone(header)) return rejected("assertion header is not a JWT header"); + if (header.value.typ !== ID_JAG_HEADER_TYP) { + return rejected(`assertion typ must be ${ID_JAG_HEADER_TYP}, got ${String(header.value.typ)}`); + } + if (header.value.alg !== "RS256") { + return rejected(`assertion alg must be RS256, got ${header.value.alg}`); + } + + const jwks = decodeJwks(input.jwks); + if (Option.isNone(jwks)) return rejected("the issuer's JWKS could not be read"); + const jwk = jwks.value.keys.find((candidate) => candidate.kid === header.value.kid); + if (!jwk) return rejected(`no JWKS key matches kid ${String(header.value.kid)}`); + + const signingInput = `${segment(input.assertion, 0)}.${segment(input.assertion, 1)}`; + const signatureValid = (() => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: an untrusted key or signature encoding must read as "invalid signature", never crash the fixture + try { + return createVerify("RSA-SHA256") + .update(signingInput) + .verify( + createPublicKey({ key: { kty: jwk.kty, n: jwk.n, e: jwk.e }, format: "jwk" }), + Buffer.from(signature, "base64url"), + ); + } catch { + return false; + } + })(); + if (!signatureValid) return rejected("assertion signature is invalid"); + + const claims = decodeClaims(claimsJson); + if (Option.isNone(claims)) { + return rejected("assertion is missing one of the required §3.1 claims"); + } + const payload = claims.value; + if (payload.iss !== input.trustedIssuer) { + return rejected(`assertion iss ${payload.iss} is not a trusted identity provider`); + } + // §4.4.1: `aud` may be a string or a single-element array, and MUST equal this + // server's issuer identifier. + const audiences = typeof payload.aud === "string" ? [payload.aud] : payload.aud; + if (audiences.length !== 1 || audiences[0] !== input.audience) { + return rejected( + `assertion aud ${JSON.stringify(payload.aud)} does not name this authorization server (${input.audience})`, + ); + } + if (payload.client_id !== input.authenticatedClientId) { + return rejected( + `assertion client_id ${payload.client_id} does not match the authenticated client ${input.authenticatedClientId}`, + ); + } + const now = input.nowSeconds ?? Math.floor(Date.now() / 1000); + if (payload.exp <= now) return rejected("assertion has expired"); + + return { ok: true, claims: payload }; +}; diff --git a/packages/core/sdk/src/testing/oauth-test-server.ts b/packages/core/sdk/src/testing/oauth-test-server.ts index 95ef31ec05..8f30acf8dc 100644 --- a/packages/core/sdk/src/testing/oauth-test-server.ts +++ b/packages/core/sdk/src/testing/oauth-test-server.ts @@ -11,6 +11,17 @@ import { HttpServerResponse, } from "effect/unstable/http"; +import { + ID_JAG_GRANT_PROFILE_URN, + ID_JAG_TOKEN_TYPE_URN, + JWT_BEARER_GRANT_TYPE_URN, + TOKEN_EXCHANGE_GRANT_TYPE_URN, + createIdJagSigningKey, + jwksDocumentFor, + signIdJag, + verifyIdJag, +} from "./id-jag-test-support"; + export class OAuthTestServerAddressError extends Data.TaggedError("OAuthTestServerAddressError")<{ readonly address: unknown; }> {} @@ -71,6 +82,43 @@ export interface OAuthTestServerOptions { * name is approved. Mirrors authorization servers (e.g. Mercury) that * reject third-party client names containing their own brand. */ readonly approveClientName?: (name: string) => boolean; + /** Act as an enterprise IdP Authorization Server for the Identity Assertion + * JWT Authorization Grant: accept RFC 8693 token exchanges, mint signed + * ID-JAGs, and publish the JWKS that verifies them. */ + readonly enterpriseIdp?: EnterpriseIdpOptions; + /** Act as a Resource Authorization Server for the same profile: advertise it + * in RFC 8414 metadata and redeem ID-JAGs presented as RFC 7523 assertions. */ + readonly enterpriseResourceServer?: EnterpriseResourceServerOptions; +} + +export interface EnterpriseIdpOptions { + /** Refuse every exchange with this RFC 6749 §5.2 error, standing in for an + * administrator policy that does not permit this client/user/target. */ + readonly denyExchangeWith?: { readonly error: string; readonly errorDescription: string }; + /** Lifetime stamped on the minted ID-JAG. May be zero or negative so a test + * can present an already-expired assertion. Default 300 (draft §4.3.4). */ + readonly idJagExpiresInSeconds?: number; + /** JWT header `typ` to stamp. Default `oauth-id-jag+jwt`; override to prove a + * Resource Authorization Server rejects a wrongly-typed assertion. */ + readonly assertionTyp?: string; + /** Scope the IdP actually grants, standing in for policy narrowing (§4.3.3). + * Omitted means "exactly what was requested". */ + readonly grantScope?: (requested: string | null) => string | null; + /** draft §5: the IdP knows which `client_id` the Resource Authorization + * Server registered for this client, which need not be the id the client + * authenticates to the IdP with. Maps IdP client id to resource client id; + * an unmapped client keeps its own id. */ + readonly resourceClientIds?: Readonly>; +} + +export interface EnterpriseResourceServerOptions { + /** The enterprise IdP whose JWKS signs assertions this server will accept. + * Resolved through the IdP's own RFC 8414 metadata, as a real server would. */ + readonly trustedIdpIssuer: string; + /** Scope this server is willing to grant. An assertion carrying more is + * narrowed to the intersection (draft §4.4.1). Omitted grants what the + * assertion carries. */ + readonly grantableScopes?: readonly string[]; } export interface OAuthTestServerShape { @@ -99,6 +147,11 @@ export interface OAuthTestServerShape { readonly clearRequests: Effect.Effect; readonly issuedAccessTokens: Effect.Effect; readonly acceptsAccessToken: (token: string) => Effect.Effect; + /** Stop honouring a token this server issued, without re-issuing anything. + * Drives the "the stored identity assertion died" tier: an enterprise IdP + * fixture rejects the exchange afterwards exactly as it would for an expired + * or revoked assertion. */ + readonly revokeAccessToken: (token: string) => Effect.Effect; readonly acceptsAuthorizationHeader: ( authorization: string | null | undefined, ) => Effect.Effect; @@ -414,6 +467,42 @@ const completeAuthorizationCodeTokenFlow = }; }); +/** RFC 8693 §3 subject token types this fixture accepts on a token exchange. + * Anything else is `invalid_request`, so a client that invents a type finds + * out here rather than against a lenient deployment. */ +const SUPPORTED_SUBJECT_TOKEN_TYPES = new Set([ + "urn:ietf:params:oauth:token-type:id_token", + "urn:ietf:params:oauth:token-type:saml2", + "urn:ietf:params:oauth:token-type:refresh_token", + "urn:ietf:params:oauth:token-type:access_token", +]); + +const JwksUriMetadata = Schema.Struct({ jwks_uri: Schema.String }); +const decodeJwksUriMetadata = Schema.decodeUnknownOption(JwksUriMetadata); + +/** Resolve a trusted IdP's signing keys the way a Resource Authorization Server + * does: read its RFC 8414 metadata, follow `jwks_uri`, fetch the key set. Any + * failure yields null, which the caller reports as `invalid_grant` — the + * fixture never falls back to trusting an unverified assertion. */ +const fetchTrustedIdpJwks = (issuer: string): Effect.Effect => + Effect.gen(function* () { + const metadataUrl = `${issuer.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`; + const metadataResponse = yield* executeOAuthHttp( + HttpClientRequest.get(metadataUrl), + metadataUrl, + ); + if (metadataResponse.status !== 200) return null; + const metadata = yield* metadataResponse.json; + const decoded = decodeJwksUriMetadata(metadata); + if (Option.isNone(decoded)) return null; + const jwksResponse = yield* executeOAuthHttp( + HttpClientRequest.get(decoded.value.jwks_uri), + decoded.value.jwks_uri, + ); + if (jwksResponse.status !== 200) return null; + return yield* jwksResponse.json; + }).pipe(Effect.catch(() => Effect.succeed(null))); + /** Parse the `scope` query param from an authorize URL into an ordered list * (empty when the parameter is absent or blank). */ export const scopesFromAuthorizeUrl = (authorizationUrl: string): readonly string[] => { @@ -465,6 +554,12 @@ export const serveOAuthTestServer = ( }); } + // Only generate a keypair for a server that actually plays the IdP role — + // RSA generation is not free, and every other fixture in the suite would + // pay for it otherwise. + const idJagKey = options.enterpriseIdp ? createIdJagSigningKey() : null; + const idJagLifetimeSeconds = options.enterpriseIdp?.idJagExpiresInSeconds ?? 300; + let issuerUrl = ""; const server = yield* serveOAuthTestHttpApp((request) => Effect.gen(function* () { @@ -496,6 +591,10 @@ export const serveOAuthTestServer = ( }); } + if (requestUrl.pathname === "/jwks" && idJagKey) { + return jsonResponse(200, jwksDocumentFor(idJagKey)); + } + if ( requestUrl.pathname === "/.well-known/oauth-authorization-server" || requestUrl.pathname === "/.well-known/openid-configuration" @@ -506,7 +605,15 @@ export const serveOAuthTestServer = ( token_endpoint: `${currentIssuerUrl}/token`, registration_endpoint: `${currentIssuerUrl}/register`, response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "refresh_token", "client_credentials"], + grant_types_supported: [ + "authorization_code", + "refresh_token", + "client_credentials", + ...(options.enterpriseIdp ? [TOKEN_EXCHANGE_GRANT_TYPE_URN] : []), + // draft §7.2: a server advertising the ID-JAG profile MUST also + // advertise the jwt-bearer grant type. + ...(options.enterpriseResourceServer ? [JWT_BEARER_GRANT_TYPE_URN] : []), + ], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: [ "none", @@ -514,6 +621,15 @@ export const serveOAuthTestServer = ( "client_secret_basic", ], scopes_supported: scopes, + ...(idJagKey ? { jwks_uri: `${currentIssuerUrl}/jwks` } : {}), + ...(options.enterpriseIdp + ? { + identity_chaining_requested_token_types_supported: [ID_JAG_TOKEN_TYPE_URN], + } + : {}), + ...(options.enterpriseResourceServer + ? { authorization_grant_profiles_supported: [ID_JAG_GRANT_PROFILE_URN] } + : {}), }); } @@ -731,6 +847,156 @@ export const serveOAuthTestServer = ( ); } + // RFC 8693 token exchange → ID-JAG (draft §4.3). The subject token is + // an access token THIS server issued: that makes "the identity + // assertion expired" expressible (revoke it) without inventing a + // second credential store. + if (grantType === TOKEN_EXCHANGE_GRANT_TYPE_URN) { + const idp = options.enterpriseIdp; + if (!idp || !idJagKey) { + return oauthError( + 400, + "unsupported_grant_type", + "This authorization server does not issue identity assertion grants", + ); + } + if (params.get("requested_token_type") !== ID_JAG_TOKEN_TYPE_URN) { + return oauthError( + 400, + "invalid_request", + `requested_token_type must be ${ID_JAG_TOKEN_TYPE_URN}`, + ); + } + const audience = params.get("audience"); + if (!audience) { + return oauthError(400, "invalid_request", "audience is required"); + } + const subjectToken = params.get("subject_token"); + const subjectTokenType = params.get("subject_token_type"); + if (!subjectToken || !subjectTokenType) { + return oauthError( + 400, + "invalid_request", + "subject_token and subject_token_type are required", + ); + } + if (!SUPPORTED_SUBJECT_TOKEN_TYPES.has(subjectTokenType)) { + return oauthError( + 400, + "invalid_request", + `Unsupported subject_token_type ${subjectTokenType}`, + ); + } + // Policy is evaluated BEFORE the subject token, so a denial cannot + // be mistaken for a credential problem by a client that only looks + // at the first failing check. + if (idp.denyExchangeWith) { + return oauthError( + 400, + idp.denyExchangeWith.error, + idp.denyExchangeWith.errorDescription, + ); + } + const subjectAccepted = yield* Ref.get(issuedAccessTokens).pipe( + Effect.map((tokens) => tokens.has(subjectToken)), + ); + if (!subjectAccepted) { + return oauthError( + 400, + "invalid_grant", + "The subject token is expired, revoked, or was not issued by this identity provider", + ); + } + const requestedScope = params.get("scope"); + const grantedScope = idp.grantScope ? idp.grantScope(requestedScope) : requestedScope; + const resourceParam = params.get("resource"); + const issuedAtSeconds = Math.floor(Date.now() / 1000); + const assertion = signIdJag({ + key: idJagKey, + typ: idp.assertionTyp, + claims: { + iss: currentIssuerUrl, + sub: `subject_${clientId}`, + aud: audience, + // draft §5: the id the RESOURCE authorization server knows this + // client by, which the IdP holds out of band. + client_id: idp.resourceClientIds?.[clientId] ?? clientId, + jti: `jti_${randomUUID()}`, + iat: issuedAtSeconds, + exp: issuedAtSeconds + idJagLifetimeSeconds, + email: `${options.defaultUsername ?? "alice"}@executor.test`, + ...(resourceParam ? { resource: resourceParam } : {}), + ...(grantedScope ? { scope: grantedScope } : {}), + }, + }); + return jsonResponse( + 200, + { + issued_token_type: ID_JAG_TOKEN_TYPE_URN, + access_token: assertion, + token_type: "N_A", + expires_in: idJagLifetimeSeconds, + ...(grantedScope ? { scope: grantedScope } : {}), + }, + { "cache-control": "no-store", pragma: "no-cache" }, + ); + } + + // RFC 7523 jwt-bearer redemption of an ID-JAG (draft §4.4). + if (grantType === JWT_BEARER_GRANT_TYPE_URN) { + const resourceServer = options.enterpriseResourceServer; + if (!resourceServer) { + return oauthError( + 400, + "unsupported_grant_type", + "This authorization server does not accept identity assertion grants", + ); + } + const assertion = params.get("assertion"); + if (!assertion) { + return oauthError(400, "invalid_request", "assertion is required"); + } + const jwks = yield* fetchTrustedIdpJwks(resourceServer.trustedIdpIssuer); + if (jwks === null) { + return oauthError( + 400, + "invalid_grant", + `The JWKS of ${resourceServer.trustedIdpIssuer} could not be retrieved`, + ); + } + const verified = verifyIdJag({ + assertion, + trustedIssuer: resourceServer.trustedIdpIssuer, + jwks, + audience: currentIssuerUrl, + authenticatedClientId: clientId, + }); + if (!verified.ok) { + return oauthError(400, "invalid_grant", verified.detail); + } + const assertedScopes = verified.claims.scope?.split(/\s+/).filter(Boolean) ?? []; + const grantable = resourceServer.grantableScopes; + const granted = + grantable === undefined + ? assertedScopes + : assertedScopes.filter((scope) => grantable.includes(scope)); + const accessToken = `at_${randomUUID()}`; + yield* Ref.update(issuedAccessTokens, (tokens) => new Set([...tokens, accessToken])); + // draft §4.4.3: no refresh token. The ID-JAG chain IS the renewal + // path, and issuing one here would let a client skip the IdP's + // policy evaluation on every subsequent renewal. + return jsonResponse( + 200, + { + access_token: accessToken, + token_type: "Bearer", + expires_in: tokenExpiresInSeconds, + ...(granted.length > 0 ? { scope: granted.join(" ") } : {}), + }, + { "cache-control": "no-store" }, + ); + } + return oauthError(400, "unsupported_grant_type", "Unsupported grant type"); } @@ -787,6 +1053,12 @@ export const serveOAuthTestServer = ( clearRequests: Ref.set(requests, []), issuedAccessTokens: accessTokenSet.pipe(Effect.map((tokens) => [...tokens])), acceptsAccessToken: (token) => accessTokenSet.pipe(Effect.map((tokens) => tokens.has(token))), + revokeAccessToken: (token) => + Ref.update(issuedAccessTokens, (tokens) => { + const next = new Set(tokens); + next.delete(token); + return next; + }), acceptsAuthorizationHeader: (authorization) => { const token = authorization?.replace(/^Bearer\s+/i, ""); return token From a38e13bf9eaf1a3d1ab9675bb15514fe858f9ab0 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:49:40 -0700 Subject: [PATCH 04/11] Cover the enterprise-managed credential lifecycle through the executor --- .../core/sdk/src/oauth-ema-lifecycle.test.ts | 282 ++++++++++++++++++ packages/core/sdk/src/oauth-ema.test.ts | 6 +- 2 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 packages/core/sdk/src/oauth-ema-lifecycle.test.ts diff --git a/packages/core/sdk/src/oauth-ema-lifecycle.test.ts b/packages/core/sdk/src/oauth-ema-lifecycle.test.ts new file mode 100644 index 0000000000..23bf9dbb8c --- /dev/null +++ b/packages/core/sdk/src/oauth-ema-lifecycle.test.ts @@ -0,0 +1,282 @@ +// --------------------------------------------------------------------------- +// The enterprise-managed credential lifecycle, driven through the executor's +// own surfaces: connect mints a connection with no browser step, and the token +// renewal that follows re-runs the ID-JAG chain without a user. +// +// The ID-JAG's own expiry has no tier here on purpose: the client never stores +// one (see `mintEnterpriseManagedAccessToken`), so "the assertion expired" can +// only be observed at the protocol boundary, where `oauth-ema.test.ts` covers +// it against a Resource Authorization Server that enforces `exp`. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + ToolAddress, + ToolName, +} from "./ids"; +import type { OAuthService } from "./oauth-client"; +import { definePlugin } from "./plugin"; +import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; +import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server"; + +const INTEG = IntegrationSlug.make("acme"); +const TEMPLATE = AuthTemplateSlug.make("oauth"); +const IDP_CLIENT = OAuthClientSlug.make("enterprise-idp"); +const RESOURCE_CLIENT = OAuthClientSlug.make("mcp-server-app"); +const CONNECTION = ConnectionName.make("work"); +const TOOL = ToolAddress.make("tools.acme.org.work.whoami"); + +const CLIENT_AT_IDP = "client-at-idp"; +const CLIENT_AT_RESOURCE = "client-at-resource"; +const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" as const; + +const oauthPlugin = definePlugin(() => ({ + id: "acme" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }] }), + describeAuthMethods: () => [ + { + id: "oauth", + label: "OAuth2", + kind: "oauth" as const, + template: String(TEMPLATE), + // A declared scope, so the assertions below follow one concrete scope + // through the exchange, the assertion, the redemption, and the stored row. + oauth: { scopes: ["mcp.read"] }, + }, + ], + invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), + extension: (ctx) => ({ + seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: {} }), + }), +}))(); + +const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; + +interface EnterpriseServers { + readonly idp: OAuthTestServerShape; + readonly resource: OAuthTestServerShape; + readonly subjectToken: string; +} + +const enterpriseServers = (options: { + readonly denyExchangeWith?: { readonly error: string; readonly errorDescription: string }; + readonly advertiseProfile?: boolean; + /** Short-lived access tokens put every resolve inside the refresh skew, which + * is how the renewal path is exercised without waiting an hour. */ + readonly resourceTokenExpiresInSeconds?: number; +}) => + Effect.gen(function* () { + const idp = yield* serveOAuthTestServer({ + clients: { [CLIENT_AT_IDP]: null }, + scopes: ["mcp.read"], + enterpriseIdp: { + resourceClientIds: { [CLIENT_AT_IDP]: CLIENT_AT_RESOURCE }, + ...(options.denyExchangeWith ? { denyExchangeWith: options.denyExchangeWith } : {}), + }, + }); + const resource = yield* serveOAuthTestServer({ + clients: { [CLIENT_AT_RESOURCE]: null }, + scopes: ["mcp.read"], + ...(options.resourceTokenExpiresInSeconds === undefined + ? {} + : { tokenExpiresInSeconds: options.resourceTokenExpiresInSeconds }), + ...(options.advertiseProfile === false + ? {} + : { enterpriseResourceServer: { trustedIdpIssuer: idp.issuerUrl } }), + }); + const session = yield* idp.completeAuthorizationCodeTokenFlow({ + clientId: CLIENT_AT_IDP, + clientSecret: "", + scopes: ["mcp.read"], + }); + return { idp, resource, subjectToken: session.accessToken } satisfies EnterpriseServers; + }); + +/** Register the two client identities the profile needs: one at the enterprise + * IdP (authenticates the token exchange) and one at the MCP server's + * authorization server (authenticates the redemption, and is the client the + * ID-JAG's `client_id` claim names). */ +const registerClients = (createClient: OAuthService["createClient"], servers: EnterpriseServers) => + Effect.gen(function* () { + yield* createClient({ + owner: "org", + slug: IDP_CLIENT, + authorizationUrl: servers.idp.authorizationEndpoint, + tokenUrl: servers.idp.tokenEndpoint, + grant: "authorization_code", + clientId: CLIENT_AT_IDP, + clientSecret: "", + }); + yield* createClient({ + owner: "org", + slug: RESOURCE_CLIENT, + authorizationUrl: servers.resource.authorizationEndpoint, + tokenUrl: servers.resource.tokenEndpoint, + grant: "id_jag", + clientId: CLIENT_AT_RESOURCE, + clientSecret: "", + resource: servers.resource.mcpResourceUrl, + }); + }); + +const startEnterpriseConnect = (servers: EnterpriseServers) => + ({ + owner: "org", + client: RESOURCE_CLIENT, + clientOwner: "org", + name: CONNECTION, + integration: INTEG, + template: TEMPLATE, + enterprise: { + idpClient: IDP_CLIENT, + idpClientOwner: "org", + subjectToken: servers.subjectToken, + subjectTokenType: ACCESS_TOKEN_TYPE, + }, + }) as const; + +const tokenExchangeCount = (servers: EnterpriseServers) => + servers.idp.requests.pipe( + Effect.map( + (entries) => + entries.filter((entry) => entry.path === "/token" && entry.body.includes("token-exchange")) + .length, + ), + ); + +describe("enterprise-managed connections", () => { + it.effect("connect mints a usable MCP credential with no browser step", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers({}); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + + const started = yield* executor.oauth.start(startEnterpriseConnect(servers)); + expect( + started.status, + "the identity assertion replaces per-server consent, so there is nothing to redirect to", + ).toBe("connected"); + if (started.status !== "connected") return; + expect(started.connection.oauthScope).toBe("mcp.read"); + + const invoked = (yield* executor.execute(TOOL, {})) as { readonly token: string }; + expect( + yield* servers.resource.acceptsAccessToken(invoked.token), + "the tool runs with a token the MCP server's authorization server issued", + ).toBe(true); + expect(yield* tokenExchangeCount(servers)).toBe(1); + }), + ), + ); + + it.effect("renews an expiring access token by re-running the chain, with no user", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + yield* executor.oauth.start(startEnterpriseConnect(servers)); + + const first = (yield* executor.execute(TOOL, {})) as { readonly token: string }; + const second = (yield* executor.execute(TOOL, {})) as { readonly token: string }; + + expect(second.token, "the expiring token was replaced").not.toBe(first.token); + expect( + yield* servers.resource.acceptsAccessToken(second.token), + "the replacement came from the resource authorization server", + ).toBe(true); + expect( + yield* tokenExchangeCount(servers), + "every renewal returns to the IdP, so enterprise policy is re-evaluated each time", + ).toBeGreaterThan(1); + }), + ), + ); + + it.effect("marks the connection expired when the stored identity assertion dies", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + yield* executor.oauth.start(startEnterpriseConnect(servers)); + + yield* servers.idp.revokeAccessToken(servers.subjectToken); + const failure = yield* executor.execute(TOOL, {}).pipe(Effect.flip); + expect(String(failure)).toContain("single sign-on"); + + const connections = yield* executor.connections.list(); + const connection = connections.find((entry) => String(entry.name) === String(CONNECTION)); + expect( + connection?.lastHealth?.status, + "a dead assertion is recorded so the accounts list shows it without a probe", + ).toBe("expired"); + }), + ), + ); + + it.effect("refuses to fall back to interactive OAuth when the IdP denies the exchange", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers({ + denyExchangeWith: { + error: "unauthorized_client", + errorDescription: "This client is not approved for the requested MCP server.", + }, + }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + + const failure = yield* executor.oauth + .start(startEnterpriseConnect(servers)) + .pipe(Effect.flip); + + expect(failure._tag).toBe("OAuthStartError"); + expect( + String(failure.message), + "the user is told their organization declined, not offered a way around it", + ).toContain("identity provider did not authorize"); + expect(String(failure.message)).toContain("unauthorized_client"); + expect( + (yield* executor.connections.list()).length, + "a denied connect leaves no half-made connection behind", + ).toBe(0); + }), + ), + ); + + it.effect("falls back to the interactive flow when the server lacks the grant profile", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers({ advertiseProfile: false }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + + const started = yield* executor.oauth.start(startEnterpriseConnect(servers)); + + expect( + started.status, + "a server that never implemented the profile still gets ordinary per-server consent", + ).toBe("redirect"); + expect( + yield* tokenExchangeCount(servers), + "no identity assertion is spent on a server that cannot accept one", + ).toBe(0); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/oauth-ema.test.ts b/packages/core/sdk/src/oauth-ema.test.ts index 56453f1a7e..5a996986be 100644 --- a/packages/core/sdk/src/oauth-ema.test.ts +++ b/packages/core/sdk/src/oauth-ema.test.ts @@ -48,7 +48,7 @@ const enterpriseFixture = ( readonly resourceAdvertisesProfile?: boolean; readonly resourceGrantableScopes?: readonly string[]; } = {}, -): Effect.Effect => +) => Effect.gen(function* () { const idp = yield* serveOAuthTestServer({ clients: { [CLIENT_AT_IDP]: null }, @@ -78,8 +78,8 @@ const enterpriseFixture = ( clientSecret: "", scopes: ["mcp.read", "mcp.write"], }); - return { idp, resource, subjectToken: session.accessToken }; - }) as Effect.Effect; + return { idp, resource, subjectToken: session.accessToken } satisfies EnterpriseFixture; + }); const chainInput = (fixture: EnterpriseFixture, scopes: readonly string[]) => ({ idp: { tokenUrl: fixture.idp.tokenEndpoint, clientId: CLIENT_AT_IDP }, From 881f9f19ae81668762388506ae6190dd58dd5067 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:17:18 -0700 Subject: [PATCH 05/11] Declare the enterprise identity provider on MCP servers and carry it 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. --- packages/core/api/src/handlers/oauth.ts | 4 ++ packages/core/api/src/integrations/api.ts | 9 +++++ packages/core/sdk/src/integration.ts | 16 +++++++- .../mcp/src/sdk/describe-auth-methods.test.ts | 37 +++++++++++++++++++ packages/plugins/mcp/src/sdk/plugin.ts | 7 ++++ packages/plugins/mcp/src/sdk/types.ts | 26 ++++++++++++- 6 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index bced024ca8..92c3e5ed72 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -163,6 +163,10 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler identityLabel: payload.identityLabel, newConnection: payload.newConnection, redirectUri: payload.redirectUri, + // Enterprise-managed authorization inputs. Ignored by every other + // grant, and REQUIRED by `id_jag` — the identity assertion is held + // by the caller, never by the server. + enterprise: payload.enterprise, }); return startResultToResponse(result); }), diff --git a/packages/core/api/src/integrations/api.ts b/packages/core/api/src/integrations/api.ts index 15161119e9..ae2c7abb00 100644 --- a/packages/core/api/src/integrations/api.ts +++ b/packages/core/api/src/integrations/api.ts @@ -18,6 +18,8 @@ import { IntegrationRemovalNotAllowedError, IntegrationSlug, InternalError, + OAuthClientSlug, + Owner, } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- @@ -54,6 +56,13 @@ const OAuthDescriptor = Schema.Struct({ registrationEndpoint: Schema.optional(Schema.String), supportsDynamicRegistration: Schema.optional(Schema.Boolean), supportsClientIdMetadataDocument: Schema.optional(Schema.Boolean), + /** MCP Enterprise-Managed Authorization: the registered OAuth app that mints + * this integration's identity assertions. Present only when the deployment + * declared one — the client names it on `oauth.start` alongside the + * assertion it holds. The interactive flow stays available regardless. */ + enterpriseIdentityProvider: Schema.optional( + Schema.Struct({ client: OAuthClientSlug, clientOwner: Owner }), + ), }); /** A single declared auth method — mirrors the SDK's `AuthMethodDescriptor`. */ diff --git a/packages/core/sdk/src/integration.ts b/packages/core/sdk/src/integration.ts index df1ea50876..4c619eb01c 100644 --- a/packages/core/sdk/src/integration.ts +++ b/packages/core/sdk/src/integration.ts @@ -1,4 +1,4 @@ -import type { IntegrationSlug } from "./ids"; +import type { IntegrationSlug, OAuthClientSlug, Owner } from "./ids"; /* Core knows only an integration's catalog identity — slug + description + which * plugin (`kind`) owns it. The type-specific shape (openapi auth templates + spec, @@ -64,6 +64,20 @@ export interface AuthMethodOAuthDescriptor { * clients. The UI can create a local public OAuth client using this host's * metadata-document URL as `client_id`, with no provider app registration. */ readonly supportsClientIdMetadataDocument?: boolean; + /** The enterprise identity provider this integration is configured to obtain + * identity assertions from (MCP Enterprise-Managed Authorization). Present + * only when the deployment has declared one for this integration; the + * connect path still verifies at discovery time that the server advertises + * the ID-JAG grant profile, and falls back to the interactive flow when it + * does not. */ + readonly enterpriseIdentityProvider?: EnterpriseIdentityProviderDescriptor; +} + +/** Which registered OAuth app stands for the enterprise IdP, so a connect + * request can name it. Carries no assertion and no secret — only the pointer. */ +export interface EnterpriseIdentityProviderDescriptor { + readonly client: OAuthClientSlug; + readonly clientOwner: Owner; } /** A single declared auth method on an integration's catalog response. */ diff --git a/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts b/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts index e8a9fe5a9f..bf7777f4ad 100644 --- a/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts +++ b/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts @@ -47,6 +47,43 @@ describe("describeMcpAuthMethods", () => { ]); }); + it("names the enterprise identity provider when the server declares one", () => { + const methods = describeMcpAuthMethods( + recordWith({ + transport: "remote", + endpoint: "https://x.example/mcp", + authenticationTemplate: [ + { + slug: "oauth2", + kind: "oauth2", + enterpriseIdentityProvider: { client: "acme-idp", clientOwner: "org" }, + }, + ], + }), + ); + + expect(methods[0]?.oauth?.enterpriseIdentityProvider).toEqual({ + client: "acme-idp", + clientOwner: "org", + }); + expect( + methods[0]?.oauth?.supportsDynamicRegistration, + "declaring an identity provider does not remove the interactive fallback", + ).toBe(true); + }); + + it("omits the enterprise identity provider when none is declared", () => { + const methods = describeMcpAuthMethods( + recordWith({ + transport: "remote", + endpoint: "https://x.example/mcp", + authenticationTemplate: [{ slug: "oauth2", kind: "oauth2" }], + }), + ); + + expect(methods[0]?.oauth).not.toHaveProperty("enterpriseIdentityProvider"); + }); + it("projects an apikey header method carrying the placement", () => { const methods = describeMcpAuthMethods( recordWith({ diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 5002994236..d401f896cc 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -710,6 +710,13 @@ export const describeMcpAuthMethods = ( oauth: { discoveryUrl: config.transport === "remote" ? config.endpoint : undefined, supportsDynamicRegistration: true, + // Present only when this server was configured with an enterprise + // identity provider. The connect path re-checks the server's metadata + // for the ID-JAG grant profile and falls back to interactive OAuth + // when it is absent, so this is an opt-in, not an override. + ...(method.enterpriseIdentityProvider === undefined + ? {} + : { enterpriseIdentityProvider: method.enterpriseIdentityProvider }), }, }; } diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index a9ebcd427f..83ca9b7a1e 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -1,4 +1,5 @@ import { Effect, Option, Schema } from "effect"; +import { OAuthClientSlug, Owner } from "@executor-js/sdk/core"; import { ApiKeyAuthMethod, ApiKeyAuthTemplate, @@ -62,9 +63,28 @@ export type McpStdioVersionNegotiation = typeof McpStdioVersionNegotiation.Type; // stored endpoints: metadata is discovered live at connect time. // --------------------------------------------------------------------------- +/** Enterprise-Managed Authorization opt-in: which registered OAuth app plays the + * enterprise identity provider for this server. Declaring one asks the connect + * path to TRY the ID-JAG grant; whether it is actually used still depends on + * the server advertising the profile in its RFC 8414 metadata, so a declaration + * here can never take an ordinary MCP server off the interactive flow. + * + * It is the POINTER only — no assertion, no secret. The identity assertion is + * supplied per connect request by whoever holds the user's single sign-on. */ +export const McpEnterpriseIdentityProvider = Schema.Struct({ + client: OAuthClientSlug, + clientOwner: Owner, +}).annotate({ + identifier: "McpEnterpriseIdentityProvider", + description: + "The registered OAuth app that stands for the enterprise identity provider minting this server's ID-JAGs.", +}); +export type McpEnterpriseIdentityProvider = typeof McpEnterpriseIdentityProvider.Type; + export const McpOAuthMethod = Schema.Struct({ slug: Schema.String, kind: Schema.Literal("oauth2"), + enterpriseIdentityProvider: Schema.optional(McpEnterpriseIdentityProvider), }); export type McpOAuthMethod = typeof McpOAuthMethod.Type; @@ -128,7 +148,11 @@ export const mcpAuthMethodFromShorthand = (auth: McpAuthShorthand): McpAuthMetho * `normalizeMcpAuthMethods` backfills it. */ export const McpAuthMethodInput = Schema.Union([ Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("none") }), - Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("oauth2") }), + Schema.Struct({ + slug: Schema.optional(Schema.String), + kind: Schema.Literal("oauth2"), + enterpriseIdentityProvider: Schema.optional(McpEnterpriseIdentityProvider), + }), // Credential methods are authored request-shaped — the ONE apikey input // dialect: `{ type: "apiKey", headers: { Authorization: ["Bearer ", // variable("token")] }, queryParams: { … } }`. Stored configs and the From 1beb10af2800512bdc1a638e07fe4d787da7f324 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:17:23 -0700 Subject: [PATCH 06/11] Match enterprise-managed failures with tagged handlers 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. --- packages/core/sdk/src/executor.ts | 72 +++++++++++-------- .../core/sdk/src/oauth-ema-lifecycle.test.ts | 4 +- packages/core/sdk/src/oauth-ema.test.ts | 36 +++++----- packages/core/sdk/src/oauth-ema.ts | 27 ++++--- 4 files changed, 77 insertions(+), 62 deletions(-) diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 12e4548668..050cb5e000 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -172,6 +172,7 @@ import { ENTERPRISE_MANAGED_PROVIDER_STATE_KEY, enterpriseManagedStateFrom, mintEnterpriseManagedAccessToken, + type EnterpriseManagedAuthorizationError, } from "./oauth-ema"; import { connectionIdentifier } from "./connection-name-identifier"; import { annotateToolResultOutcome } from "./tool-result"; @@ -1872,6 +1873,13 @@ export const createExecutor = + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: see above + cause.message; + /** Re-mint an enterprise-managed access token: exchange the stored identity * assertion for a fresh ID-JAG at the enterprise IdP, then redeem it at the * MCP server's authorization server. Runs with no user interaction, which @@ -1937,54 +1945,60 @@ export const createExecutor = { - // A policy denial and a dead identity assertion are both definitive - // — neither retries into success — but they are DIFFERENT products: - // one is "your administrator has not allowed this", the other is - // "sign in again". Only the transport failure stays a StorageError - // so the next invoke retries it. - switch (cause._tag) { - case "EmaPolicyDenied": - return new CredentialResolutionError({ + // A policy denial and a dead identity assertion are both definitive — + // neither retries into success — but they are DIFFERENT products: one + // is "your administrator has not allowed this", the other is "sign in + // again". Only the transport failure stays a StorageError so the next + // invoke retries it. + Effect.catchTags({ + EmaPolicyDenied: (cause) => + Effect.fail( + new CredentialResolutionError({ owner, integration: IntegrationSlug.make(row.integration), name: ConnectionName.make(row.name), - message: cause.message, + message: enterpriseManagedMessage(cause), reauthRequired: true, blockedByAdmin: true, oauthErrorCode: cause.error, - }); - case "EmaSubjectTokenRejected": - return new CredentialResolutionError({ + }), + ), + EmaSubjectTokenRejected: (cause) => + Effect.fail( + new CredentialResolutionError({ owner, integration: IntegrationSlug.make(row.integration), name: ConnectionName.make(row.name), - message: cause.message, + message: enterpriseManagedMessage(cause), reauthRequired: true, - }); - case "EmaRedemptionRejected": - return new CredentialResolutionError({ + }), + ), + EmaRedemptionRejected: (cause) => + Effect.fail( + new CredentialResolutionError({ owner, integration: IntegrationSlug.make(row.integration), name: ConnectionName.make(row.name), - message: cause.message, + message: enterpriseManagedMessage(cause), reauthRequired: cause.error === "invalid_grant", ...(cause.error === undefined ? {} : { oauthErrorCode: cause.error }), - }); - case "EmaUpstreamUnavailable": - return new StorageError({ message: cause.message, cause }); - // The profile is confirmed at connect and never re-discovered on - // this path, so this constructor is unreachable here; surface it - // as a reconnect rather than pretending it cannot happen. - case "EmaGrantProfileUnsupported": - return new CredentialResolutionError({ + }), + ), + EmaUpstreamUnavailable: (cause) => + Effect.fail(new StorageError({ message: enterpriseManagedMessage(cause), cause })), + // The profile is confirmed at connect and never re-discovered on + // this path, so this arm is unreachable here; surface it as a + // reconnect rather than pretending it cannot happen. + EmaGrantProfileUnsupported: (cause) => + Effect.fail( + new CredentialResolutionError({ owner, integration: IntegrationSlug.make(row.integration), name: ConnectionName.make(row.name), - message: cause.message, + message: enterpriseManagedMessage(cause), reauthRequired: true, - }); - } + }), + ), }), Effect.tapError((error) => Predicate.isTagged(error, "CredentialResolutionError") && error.reauthRequired === true diff --git a/packages/core/sdk/src/oauth-ema-lifecycle.test.ts b/packages/core/sdk/src/oauth-ema-lifecycle.test.ts index 23bf9dbb8c..bae79461bb 100644 --- a/packages/core/sdk/src/oauth-ema-lifecycle.test.ts +++ b/packages/core/sdk/src/oauth-ema-lifecycle.test.ts @@ -10,7 +10,7 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Predicate } from "effect"; import { AuthTemplateSlug, @@ -244,7 +244,7 @@ describe("enterprise-managed connections", () => { .start(startEnterpriseConnect(servers)) .pipe(Effect.flip); - expect(failure._tag).toBe("OAuthStartError"); + expect(Predicate.isTagged(failure, "OAuthStartError")).toBe(true); expect( String(failure.message), "the user is told their organization declined, not offered a way around it", diff --git a/packages/core/sdk/src/oauth-ema.test.ts b/packages/core/sdk/src/oauth-ema.test.ts index 5a996986be..a915a3e944 100644 --- a/packages/core/sdk/src/oauth-ema.test.ts +++ b/packages/core/sdk/src/oauth-ema.test.ts @@ -1,3 +1,6 @@ +// oxlint-disable executor/no-unknown-error-message -- boundary: every `.message` +// read below is on a TYPED error under test (EMA / OAuth2Error), where the +// rendered message is the assertion target. // --------------------------------------------------------------------------- // Protocol conformance for MCP Enterprise-Managed Authorization // (draft-ietf-oauth-identity-assertion-authz-grant-04). @@ -11,14 +14,13 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Effect, Ref, Schema } from "effect"; +import { Effect, Predicate, Ref, Schema } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; import { supportsIdJagGrantProfile } from "./oauth-discovery"; import { EmaGrantProfileUnsupported, mintEnterpriseManagedAccessToken, - permitsInteractiveFallback, runEnterpriseManagedAuthorization, type EnterpriseManagedAuthorizationError, } from "./oauth-ema"; @@ -98,6 +100,7 @@ const chainInput = (fixture: EnterpriseFixture, scopes: readonly string[]) => ({ const resourceMetadata = (fixture: EnterpriseFixture) => Effect.gen(function* () { const response = yield* Effect.promise(() => + // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: reads the fixture's metadata document exactly as the connect path's discovery does globalThis.fetch(`${fixture.resource.issuerUrl}/.well-known/oauth-authorization-server`), ); return yield* Effect.promise(() => response.json() as Promise); @@ -227,10 +230,9 @@ describe("enterprise-managed authorization: failure taxonomy", () => { resourceAuthorizationServer: { clientId: CLIENT_AT_RESOURCE }, }).pipe(Effect.flip); - expect(error._tag).toBe("EmaGrantProfileUnsupported"); expect( - permitsInteractiveFallback(error), - "a server that does not implement the profile gets the ordinary OAuth flow", + Predicate.isTagged(error, "EmaGrantProfileUnsupported"), + "the one tag the connect path catches: a server that does not implement the profile gets the ordinary OAuth flow", ).toBe(true); expect( (yield* fixture.idp.requests).some((entry) => entry.body.includes("token-exchange")), @@ -262,13 +264,12 @@ describe("enterprise-managed authorization: failure taxonomy", () => { }, ).pipe(Effect.flip); - expect(error._tag).toBe("EmaPolicyDenied"); - if (error._tag !== "EmaPolicyDenied") return; - expect(error.error).toBe("unauthorized_client"); expect( - permitsInteractiveFallback(error), - "offering interactive OAuth here would route the user around enterprise policy", - ).toBe(false); + Predicate.isTagged(error, "EmaPolicyDenied"), + "offering interactive OAuth here would route the user around enterprise policy, so this tag is NOT the one the connect path catches", + ).toBe(true); + if (!Predicate.isTagged(error, "EmaPolicyDenied")) return; + expect(error.error).toBe("unauthorized_client"); expect( (yield* fixture.resource.requests).some((entry) => entry.path === "/token"), "a denied exchange never reaches the resource authorization server", @@ -287,8 +288,10 @@ describe("enterprise-managed authorization: failure taxonomy", () => { chainInput(fixture, ["mcp.read"]), ).pipe(Effect.flip); - expect(error._tag).toBe("EmaSubjectTokenRejected"); - expect(permitsInteractiveFallback(error)).toBe(false); + expect( + Predicate.isTagged(error, "EmaSubjectTokenRejected"), + "a dead assertion needs a fresh single sign-on, not the interactive per-server flow", + ).toBe(true); }), ), ); @@ -336,7 +339,7 @@ describe("enterprise-managed authorization: failure taxonomy", () => { chainInput(fixture, ["mcp.read"]), ).pipe(Effect.flip); - expect(error._tag).toBe("EmaRedemptionRejected"); + expect(Predicate.isTagged(error, "EmaRedemptionRejected")).toBe(true); expect(error.message).toContain("typ must be oauth-id-jag+jwt"); }), ), @@ -352,7 +355,7 @@ describe("enterprise-managed authorization: failure taxonomy", () => { chainInput(fixture, ["mcp.read"]), ).pipe(Effect.flip); - expect(error._tag).toBe("EmaRedemptionRejected"); + expect(Predicate.isTagged(error, "EmaRedemptionRejected")).toBe(true); expect(error.message).toContain("has expired"); }), ), @@ -371,7 +374,7 @@ describe("enterprise-managed authorization: failure taxonomy", () => { chainInput(fixture, ["mcp.read"]), ).pipe(Effect.flip); - expect(error._tag).toBe("EmaRedemptionRejected"); + expect(Predicate.isTagged(error, "EmaRedemptionRejected")).toBe(true); expect(error.message).toContain("does not match the authenticated client"); }), ), @@ -390,6 +393,7 @@ describe("enterprise-managed authorization: failure taxonomy", () => { }); const response = yield* Effect.promise(() => + // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: presents the assertion as a raw bearer token, which no product code path would do globalThis.fetch(fixture.resource.mcpResourceUrl, { method: "POST", headers: { authorization: `Bearer ${grant.assertion}` }, diff --git a/packages/core/sdk/src/oauth-ema.ts b/packages/core/sdk/src/oauth-ema.ts index a05ba7aa25..97f58ad8b6 100644 --- a/packages/core/sdk/src/oauth-ema.ts +++ b/packages/core/sdk/src/oauth-ema.ts @@ -185,14 +185,6 @@ export type EnterpriseManagedAuthorizationError = | EmaRedemptionRejected | EmaUpstreamUnavailable; -/** Whether a failure leaves the ordinary interactive OAuth flow available. - * Exactly one failure mode does. Everything else is either an enterprise - * policy decision that must not be routed around, or a condition an - * interactive flow would not fix. */ -export const permitsInteractiveFallback = ( - error: EnterpriseManagedAuthorizationError, -): error is EmaGrantProfileUnsupported => error._tag === "EmaGrantProfileUnsupported"; - // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- @@ -253,23 +245,28 @@ export interface EnterpriseManagedGrant { // --------------------------------------------------------------------------- const exchangeFailure = (cause: OAuth2Error): EnterpriseManagedAuthorizationError => { + // 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: cause.message }); + return new EmaSubjectTokenRejected({ detail }); } if (cause.error !== undefined) { - return new EmaPolicyDenied({ error: cause.error, detail: cause.message }); + return new EmaPolicyDenied({ error: cause.error, detail }); } - return new EmaUpstreamUnavailable({ step: "token-exchange", detail: cause.message }); + return new EmaUpstreamUnavailable({ step: "token-exchange", detail }); }; -const redemptionFailure = (cause: OAuth2Error): EnterpriseManagedAuthorizationError => - cause.error === undefined - ? new EmaUpstreamUnavailable({ step: "redemption", detail: cause.message }) - : new EmaRedemptionRejected({ error: cause.error, detail: cause.message }); +const redemptionFailure = (cause: OAuth2Error): EnterpriseManagedAuthorizationError => { + // 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. From ceddd6ae170065d5f021446e2a4f7d5a401b0b6d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:17:28 -0700 Subject: [PATCH 07/11] Cover enterprise-managed authorization against the Okta and MCP emulators 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. --- bun.lock | 4 +- e2e/package.json | 2 +- .../mcp-enterprise-managed-auth.test.ts | 437 ++++++++++++++++++ 3 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 e2e/selfhost/mcp-enterprise-managed-auth.test.ts diff --git a/bun.lock b/bun.lock index a5060676ac..4aebc26736 100644 --- a/bun.lock +++ b/bun.lock @@ -355,7 +355,7 @@ "version": "0.0.40", "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.13.9", + "@executor-js/emulate": "^0.14.0", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -1772,7 +1772,7 @@ "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], - "@executor-js/emulate": ["@executor-js/emulate@0.13.9", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-GXuooRKtJPrWp5AEdcE6w0DeIt+TF/bonV1bzuZHX0khx2bsUrE4z7gKK/5IMxYEr+ifgVk7HWnxLnhx8BHNGg=="], + "@executor-js/emulate": ["@executor-js/emulate@0.14.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-SqxMifp1E3FFQ+A8Q/xVTuOxPd75Z/kWAwYgqS/9W+WnVspoAs1ZYAnTfLxsaEkwsURmngqAkc83Z+oySDjYdA=="], "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], diff --git a/e2e/package.json b/e2e/package.json index 2f8bd94b0d..50916dbf5f 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.13.9", + "@executor-js/emulate": "^0.14.0", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/e2e/selfhost/mcp-enterprise-managed-auth.test.ts b/e2e/selfhost/mcp-enterprise-managed-auth.test.ts new file mode 100644 index 0000000000..c8a6261c63 --- /dev/null +++ b/e2e/selfhost/mcp-enterprise-managed-auth.test.ts @@ -0,0 +1,437 @@ +// Selfhost-only: MCP Enterprise-Managed Authorization (the ID-JAG grant +// profile of draft-ietf-oauth-identity-assertion-authz-grant) driven all the +// way through the product against TWO emulators. +// +// Okta — the enterprise identity provider. Runs the real OIDC single +// sign-on that hands the host an ID token, mints ID-JAGs from it +// over RFC 8693 token exchange, and enforces an administrator +// policy table while doing so. +// MCP — the Resource Authorization Server AND the MCP server. Advertises +// `urn:ietf:params:oauth:grant-profile:id-jag` in its RFC 8414 +// metadata, redeems ID-JAGs over RFC 7523 jwt-bearer, and serves the +// tools behind the resulting access token. +// +// The claim under test is the whole point of the profile: a user who is +// already signed in to the enterprise IdP connects an MCP server with NO +// browser consent step, and the IdP — not the user, and not executor — decides +// whether that is allowed. So there are two phases against the same wiring: +// +// 1. empty policy table → `oauth.start` returns `connected` outright (never +// `redirect`), and a tool call rides the minted token. +// 2. one DENY policy → `oauth.start` FAILS as blocked-by-admin. Executor +// must NOT quietly fall back to the interactive per-server OAuth flow, +// because that would route the user straight around the control the +// enterprise just exercised. +// +// Both emulators' request ledgers are the proof. Assertions on executor's own +// responses only show what executor believes; the ledgers show the upstream +// calls it actually made — and, for the denial, the ones it did NOT make. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:net"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { createEmulator, type Emulator, type LedgerEntry } 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, Target } from "../src/services"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; +const JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; +const ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"; + +// The Okta emulator's default seed: one user on the `default` authorization +// server. The OAuth client is minted per run so nothing depends on the sample +// client's id. +const OKTA_USER = "testuser@okta.local"; +const OKTA_AUTH_SERVER = "default"; +// Where the IdP sends the SSO code. Nothing listens on it — the scenario reads +// the code straight off the 302, exactly as a host embedding this flow would +// after its own callback fired. +const SSO_REDIRECT_URI = "http://localhost:3000/callback"; + +// What the MCP emulator advertises in both its RFC 9728 and RFC 8414 metadata. +// The integration declares no scopes of its own, so executor discovers these +// at connect and asks the IdP for exactly them. +const SERVER_SCOPES = ["repo", "read:user"] as const; + +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 on purpose: this scenario asserts on behavior that + * shipped in `@executor-js/emulate` 0.14.0, and the npm package is the version + * this checkout pins — a hosted instance is whatever was last deployed. */ +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 to the IdP, ending with the ID token the host holds on the + * user's behalf (EMA profile §3). This is the ONE step outside the product: + * the profile leaves "where the identity assertion comes from" to the host, + * and executor is handed the result on the connect request. */ +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 ledger = (instance: Emulator) => Effect.promise(() => instance.ledger.list()); + +const entryFor = (entries: readonly LedgerEntry[], operationId: string): LedgerEntry | undefined => + entries.find((entry) => entry.operationId === operationId); + +// Sandbox code for the agent path: one addressed MCP tool call, with the +// ToolResult envelope returned as a value (tool failures are values here). +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 }; +`; + +type SandboxToolOutcome = { + readonly ok: boolean; + readonly payload?: { readonly login?: string }; +}; + +scenario( + "MCP enterprise-managed authorization · an Okta ID-JAG connects and calls an MCP server with no consent step, and admin policy blocks it", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + 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 — the same client the + // user signed in to, presenting itself to the Resource Authorization + // Server (draft §5 client continuity: the ID-JAG's `client_id` claim + // names it, and the redemption authenticates as it). + 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")); + const idpClient = OAuthClientSlug.make(freshSlug("ema_idp")); + const serverClient = OAuthClientSlug.make(freshSlug("ema_server")); + const template = AuthTemplateSlug.make("oauth2"); + + // The MCP server, declaring which registered app plays its enterprise + // IdP. That declaration is the ONLY opt-in: the connect path still has + // to see the grant profile in the server's own metadata. + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "Enterprise-managed MCP (emulate)", + endpoint: mcpEndpoint, + slug: String(integration), + authenticationTemplate: [ + { + kind: "oauth2", + enterpriseIdentityProvider: { client: idpClient, clientOwner: "org" }, + }, + ], + }, + }); + + yield* Effect.ensuring( + Effect.gen(function* () { + // The client's registration AT THE IdP. Never run as a flow — it + // exists so the token exchange can authenticate as this client. + yield* client.oauth.createClient({ + payload: { + owner: "org", + slug: idpClient, + authorizationUrl: idpAuthorizationUrl, + tokenUrl: idpTokenUrl, + grant: "authorization_code", + clientId, + clientSecret, + }, + }); + // The client's registration AT THE RESOURCE AUTHORIZATION SERVER. + // `id_jag` is what puts the connect path on the enterprise-managed + // branch; `resource` is the RFC 9728 identifier 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, + }, + }); + + // The catalog carries the pointer through to the client, which is + // how a real console knows WHICH app to name on the connect request. + // Everything below drives off the projected descriptor, not the + // local variable, so a broken projection fails this scenario. + const catalog = yield* client.integrations.get({ params: { slug: integration } }); + const declared = catalog.authMethods.find((method) => method.kind === "oauth"); + expect( + declared?.oauth?.enterpriseIdentityProvider, + "the catalog names the enterprise identity provider for this server", + ).toEqual({ client: String(idpClient), clientOwner: "org" }); + expect( + declared?.oauth?.supportsDynamicRegistration, + "declaring an IdP leaves the interactive flow advertised", + ).toBe(true); + const enterprise = declared?.oauth?.enterpriseIdentityProvider; + if (!enterprise) return; + + // --------------------------------------------------------------- + // Phase 1 — empty policy table: the IdP authorizes the exchange. + // --------------------------------------------------------------- + const connected = yield* client.oauth.start({ + payload: { + owner: "org", + client: serverClient, + clientOwner: "org", + name: ConnectionName.make("main"), + integration, + template, + enterprise: { + idpClient: enterprise.client, + idpClientOwner: enterprise.clientOwner, + subjectToken, + subjectTokenType: ID_TOKEN_TYPE, + }, + }, + }); + + // The headline: connected outright. A `redirect` here would mean the + // user was sent through per-server consent after all. + expect(connected.status, "the enterprise grant connects with no authorize redirect").toBe( + "connected", + ); + if (connected.status !== "connected") return; + expect( + connected.connection.oauthScope?.split(" ").sort(), + "the connection carries the scopes the IdP granted", + ).toEqual([...SERVER_SCOPES].sort()); + + const executed = yield* client.executions.execute({ + payload: { code: callGetMeCode(String(integration), "main"), autoApprove: true }, + }); + expect(executed.status, "the tool call completed").toBe("completed"); + const outcome = JSON.parse(executed.text) as SandboxToolOutcome; + expect(outcome.ok, executed.text).toBe(true); + + // --- Ledger: what executor actually asked the IdP for. ----------- + const oktaEntries = yield* ledger(okta); + const exchange = entryFor(oktaEntries, "okta.oauth.tokenExchange"); + expect(exchange, "executor ran an RFC 8693 exchange at the IdP").toBeTruthy(); + expect(exchange?.response.status).toBe(200); + // `requested_token_type` / `subject_token_type` are absent here + // because the ledger redacts every `*token*` field before recording + // it — the assertion that they carry the id-jag and id_token URNs + // belongs to the hermetic protocol tests, which read the wire. + expect( + exchange?.request.body, + "the exchange names the MCP server's authorization server and resource", + ).toMatchObject({ + grant_type: TOKEN_EXCHANGE_GRANT_TYPE, + audience: mcp.url, + resource: mcpEndpoint, + client_id: clientId, + scope: SERVER_SCOPES.join(" "), + }); + + // --- Ledger: what executor did with the ID-JAG it got back. ------ + const mcpEntries = yield* ledger(mcp); + const redemption = entryFor(mcpEntries, "mcp.oauth.jwtBearer"); + expect( + redemption, + "executor redeemed the ID-JAG at the resource authorization server", + ).toBeTruthy(); + expect(redemption?.response.status).toBe(200); + expect(redemption?.request.body).toMatchObject({ + grant_type: JWT_BEARER_GRANT_TYPE, + client_id: clientId, + }); + expect( + mcpEntries.filter((entry) => entry.path === "/authorize"), + "no interactive authorization request was ever made", + ).toEqual([]); + + // --- Ledger: the tool call rode the token the chain minted. ------ + const toolCall = mcpEntries.find( + (entry) => entry.path === "/mcp" && entry.method === "POST", + ); + expect( + toolCall?.identity.user, + "the MCP server saw the enterprise user with the granted scopes", + ).toMatchObject({ scopes: [...SERVER_SCOPES] }); + + // --------------------------------------------------------------- + // Phase 2 — administrator policy denies this client. 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); + + // Blocked-by-admin reaches the user in those words, carrying the + // IdP's own RFC 8693 §2.2.2 code so support can trace the decision. + expect(blocked._tag).toBe("OAuthStartError"); + expect( + blocked.message, + "the denial is surfaced as an organization decision, not a credential problem", + ).toContain("identity provider did not authorize"); + expect(blocked.message).toContain("invalid_target"); + + const deniedEntries = yield* ledger(okta); + const denied = entryFor(deniedEntries, "okta.oauth.tokenExchange"); + expect(denied?.response.status, "the IdP refused the exchange").toBe(400); + expect(denied?.response.body).toMatchObject({ error: "invalid_target" }); + + // THE anti-fallback claim. If executor had quietly offered the + // ordinary per-server flow, the MCP server would have seen an + // authorize request — or another redemption attempt. + const afterDenial = yield* ledger(mcp); + 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(["main"]); + }), + Effect.gen(function* () { + yield* client.connections + .remove({ params: { owner: "org", integration, name: ConnectionName.make("main") } }) + .pipe(Effect.ignore); + yield* client.oauth + .removeClient({ params: { slug: serverClient }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* client.oauth + .removeClient({ params: { slug: idpClient }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* client.mcp.removeServer({ params: { slug: integration } }).pipe(Effect.ignore); + }), + ); + }), + ), +); From 0759044fa8a1357dfb17270313980aeb91e007ed Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:07:33 -0700 Subject: [PATCH 08/11] Classify token-endpoint error bodies in one place 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. --- packages/core/sdk/src/oauth-helpers.ts | 229 ++++++++++++++----------- 1 file changed, 125 insertions(+), 104 deletions(-) diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index 4c333eeab2..68622f2546 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -324,13 +324,40 @@ const tokenEndpointHttpSummary = async (response: Response): Promise => return parts.join("; "); }; -const bodyPreviewFromResponse = async (response: Response): Promise => { - const text = await Promise.resolve() - .then(() => response.clone().text()) +/** Read a response body as text without throwing. Null means the body could not + * be read at all — already consumed, or the connection died mid-stream — which + * is a DIFFERENT outcome from a body that read fine and said something we did + * not expect. The read is passed as a thunk so that `.clone()` throwing on an + * already-consumed body is caught here too, rather than escaping as a defect. */ +const safeBodyText = async (read: () => Promise): Promise => + Promise.resolve() + .then(read) .then( - (value) => value.trim(), - () => "", + (value) => value, + () => null, ); + +/** Structurally probe an untrusted upstream body. Returns `undefined` rather + * than a fabricated value when it is not JSON; the caller's schema decode + * decides what an absent envelope means. */ +const safeJson = (text: string): unknown => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing an untrusted token-endpoint body; unparseable means "no OAuth envelope" + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: same untrusted-body probe; the value is only decoded through a schema or inspected against a closed code set + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +}; + +/** Read and JSON-probe a response body without consuming the caller's copy. */ +const safeJsonFromResponse = async (response: Response): Promise => { + const text = await safeBodyText(() => response.clone().text()); + return text === null ? undefined : safeJson(text); +}; + +const bodyPreviewFromResponse = async (response: Response): Promise => { + const text = (await safeBodyText(() => response.clone().text()))?.trim() ?? ""; if (!text) return undefined; const redacted = redactTokenEndpointBody(text.replaceAll(/\s+/g, " ")); return redacted.length > 500 ? `${redacted.slice(0, 500)}...` : redacted; @@ -356,22 +383,14 @@ const rfc6749CodeFromCandidate = (candidate: unknown): string | undefined => { ); }; -/** Recover the AS's §5.2 verdict from an error body oauth4webapi refused to - * parse. Some ASes wrap the code in a non-conform envelope — Datadog answers - * refresh grants with `{"errors": ["invalid_grant - Invalid or expired - * refresh token or code verifier."]}` — and without this probe a definitive - * `invalid_grant` (dead refresh token, reconnect required) is classified as - * a transient failure and retried forever instead of surfacing a re-auth. */ -const oauthErrorCodeFromNonConformBody = (text: string): string | undefined => { - const parsed: unknown = (() => { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing an untrusted upstream body that already failed spec parsing; a parse failure just means "no recoverable code" - try { - // oxlint-disable-next-line executor/no-json-parse -- boundary: same untrusted-body probe; the value is only structurally inspected against the closed §5.2 code set, never decoded into domain types - return JSON.parse(text) as unknown; - } catch { - return undefined; - } - })(); +/** Recover the AS's §5.2 verdict from an already-parsed error body that is not + * a conform RFC 6749 envelope. Some ASes wrap the code in a shape of their own + * — Datadog answers refresh grants with `{"errors": ["invalid_grant - Invalid + * or expired refresh token or code verifier."]}` — and without this probe a + * definitive `invalid_grant` (dead refresh token, reconnect required) is + * classified as a transient failure and retried forever instead of surfacing a + * re-auth. Takes the parsed value, not the text, so the caller parses once. */ +const oauthErrorCodeFromNonConformBody = (parsed: unknown): string | undefined => { if (typeof parsed !== "object" || parsed === null) return undefined; const envelope = parsed as { readonly error?: unknown; readonly errors?: unknown }; const direct = rfc6749CodeFromCandidate(envelope.error); @@ -385,6 +404,40 @@ const oauthErrorCodeFromNonConformBody = (text: string): string | undefined => { return undefined; }; +// The RFC 6749 §5.2 error envelope. Its code is NOT constrained to the closed +// set above: extension grants define their own (RFC 8693 §2.2.2 adds +// `invalid_target`, which the ID-JAG exchange leans on), and a conform envelope +// is the authorization server naming its own verdict. Only the free-text +// recovery has to stay closed. +const TokenErrorEnvelopeSchema = Schema.Struct({ + error: Schema.String, + error_description: Schema.optional(Schema.String), +}); +const decodeTokenErrorEnvelope = Schema.decodeUnknownOption(TokenErrorEnvelopeSchema); + +/** The authorization server's verdict as read off an error-response body: its + * conform §5.2 envelope when it sent one, otherwise the closed-set recovery + * from a non-conform envelope. ONE classifier, so every token path — code + * exchange, refresh, client credentials, ID-JAG exchange, ID-JAG redemption — + * reaches the same conclusion about the same body. */ +const oauthErrorFromResponseBody = ( + text: string, +): { readonly code: string; readonly description?: string } | undefined => { + const parsed = safeJson(text); + return Option.match(decodeTokenErrorEnvelope(parsed), { + onNone: () => { + const code = oauthErrorCodeFromNonConformBody(parsed); + return code === undefined ? undefined : { code }; + }, + onSome: (envelope) => ({ + code: envelope.error, + ...(envelope.error_description === undefined + ? {} + : { description: envelope.error_description }), + }), + }); +}; + const toOAuth2Error = (cause: unknown): OAuth2Error => { if (isOAuth2Error(cause)) return cause; if (typeof cause === "object" && cause !== null) { @@ -412,30 +465,41 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { }); }; -const toOAuth2ErrorWithHttpSummary = (cause: unknown): Effect.Effect => { +/** Turn whatever a token request failed with into an `OAuth2Error` carrying the + * HTTP summary and, when the body admits one, the authorization server's own + * §5.2 code. + * + * `fallbackMessage` is for the paths that hold the error Response directly + * rather than catching a thrown oauth4webapi error: there is no library + * message to build on, so the caller names the step instead. */ +const toOAuth2ErrorWithHttpSummary = ( + cause: unknown, + options?: { readonly fallbackMessage?: string }, +): Effect.Effect => { if (isOAuth2Error(cause)) return Effect.succeed(cause); const base = toOAuth2Error(cause); const response = responseFromOAuthErrorCause(cause); if (!response) return Effect.succeed(base); return Effect.promise(async () => { const summary = await tokenEndpointHttpSummary(response); - // A 4xx the spec parser refused may still carry the AS's verdict in a - // non-conform envelope; recover it so classification (invalid_grant → - // reauth-required) sees the code instead of a code-less "transient". + // A 4xx the spec parser refused may still carry the AS's verdict in its + // body; recover it so classification (invalid_grant → reauth-required, + // invalid_target → blocked-by-admin) sees the code instead of a code-less + // "transient". 5xx is left code-less on purpose: it is a transport verdict. const recovered = base.error === undefined && response.status >= 400 && response.status < 500 - ? oauthErrorCodeFromNonConformBody( - await Promise.resolve() - .then(() => response.clone().text()) - .then( - (value) => value, - () => "", - ), - ) + ? oauthErrorFromResponseBody((await safeBodyText(() => response.clone().text())) ?? "") : undefined; + const headline = options?.fallbackMessage ?? base.message; + const described = + options?.fallbackMessage === undefined || recovered === undefined + ? headline + : `${headline}: ${recovered.code}${ + recovered.description === undefined ? "" : ` — ${recovered.description}` + }`; return new OAuth2Error({ - message: `${base.message} (${summary})`, - error: base.error ?? recovered, + message: `${described} (${summary})`, + error: base.error ?? recovered?.code, cause, }); }); @@ -444,6 +508,20 @@ const toOAuth2ErrorWithHttpSummary = (cause: unknown): Effect.Effect => toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error))); +/** Fail from a token-endpoint error Response the caller holds directly — the + * `genericTokenEndpointRequest` paths, where oauth4webapi hands back the raw + * response instead of throwing. Classification runs through the SAME machinery + * every other token path uses, including the non-conform recovery: without it + * an IdP answering `{"errors":["invalid_grant - …"]}` reads as a transport + * failure and gets retried forever instead of asking for a fresh sign-on. */ +const failOAuth2FromErrorResponse = ( + response: Response, + fallbackMessage: string, +): Effect.Effect => + toOAuth2ErrorWithHttpSummary(response, { fallbackMessage }).pipe( + Effect.flatMap((error) => Effect.fail(error)), + ); + /** Trace one token-endpoint round trip. This is the ONLY place a token request * can be observed: oauth4webapi drives the raw global `fetch`, not Effect's * HttpClient, so no `http.client` span exists underneath — without this span @@ -674,14 +752,7 @@ type NestedAuthedUserGrant = { const nestedAuthedUserGrant = async ( response: Response, ): Promise => { - const body = await response - .clone() - .json() - .then( - (value: unknown) => value, - () => null, - ); - const decoded = decodeNestedAuthedUserScope(body); + const decoded = decodeNestedAuthedUserScope(await safeJsonFromResponse(response)); if (Option.isNone(decoded)) return undefined; const normalized = decoded.value.authed_user.scope .split(/[\s,]+/) @@ -707,13 +778,7 @@ const nestedAuthedUserGrant = async ( // don't care about. Strip the field before delegation, after extracting the // optional display label when the token endpoint returned OIDC account claims. const stripIdToken = async (response: Response): Promise => { - const body = await response - .clone() - .json() - .then( - (value: unknown) => value, - () => null, - ); + const body = await safeJsonFromResponse(response); if (!body || typeof body !== "object" || !("id_token" in (body as Record))) { return { response }; } @@ -985,12 +1050,6 @@ export const refreshAccessToken = ( // against the exact shape the draft specifies. // --------------------------------------------------------------------------- -const TokenErrorEnvelopeSchema = Schema.Struct({ - error: Schema.String, - error_description: Schema.optional(Schema.String), -}); -const decodeTokenErrorEnvelope = Schema.decodeUnknownOption(TokenErrorEnvelopeSchema); - const IdJagResponseSchema = Schema.Struct({ access_token: Schema.String, issued_token_type: Schema.String, @@ -1012,44 +1071,6 @@ export type IdJagGrant = { readonly expiresIn?: number; }; -const failOAuth2FromErrorResponse = ( - response: Response, - fallbackMessage: string, -): Effect.Effect => - Effect.promise(async () => { - const text = await Promise.resolve() - .then(() => response.clone().text()) - .then( - (value) => value, - () => "", - ); - const envelope = text.length > 0 ? decodeTokenErrorEnvelope(safeJson(text)) : Option.none(); - const summary = await tokenEndpointHttpSummary(response); - return Option.match(envelope, { - onNone: () => new OAuth2Error({ message: `${fallbackMessage} (${summary})` }), - onSome: (parsed) => - new OAuth2Error({ - message: `${fallbackMessage}: ${parsed.error}${ - parsed.error_description ? ` — ${parsed.error_description}` : "" - } (${summary})`, - error: parsed.error, - }), - }); - }).pipe(Effect.flatMap((error) => Effect.fail(error))); - -/** Structurally probe an untrusted upstream body. Returns `undefined` rather - * than a fabricated value when it is not JSON; the caller's schema decode - * decides what an absent envelope means. */ -const safeJson = (text: string): unknown => { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing an untrusted token-endpoint error body; unparseable means "no RFC 6749 envelope" - try { - // oxlint-disable-next-line executor/no-json-parse -- boundary: same untrusted-body probe; the value is immediately decoded through TokenErrorEnvelopeSchema - return JSON.parse(text) as unknown; - } catch { - return undefined; - } -}; - export type ExchangeSubjectTokenForIdJagInput = { /** The enterprise IdP's token endpoint. */ readonly tokenUrl: string; @@ -1126,16 +1147,16 @@ export const exchangeSubjectTokenForIdJag = ( return yield* failOAuth2FromErrorResponse(response, "ID-JAG token exchange was rejected"); } - const body = yield* Effect.promise(() => - response - .clone() - .json() - .then( - (value: unknown) => value, - () => null, - ), - ); - const parsed = yield* decodeIdJagResponse(body).pipe( + // Nothing else reads this body, so it is consumed directly. A read failure + // is its own outcome: "the IdP's answer never arrived" is not the same + // verdict as "the IdP answered with something that is not an ID-JAG". + const text = yield* Effect.promise(() => safeBodyText(() => response.text())); + if (text === null) { + return yield* new OAuth2Error({ + message: "The ID-JAG token exchange response body could not be read", + }); + } + const parsed = yield* decodeIdJagResponse(safeJson(text)).pipe( Effect.mapError( (cause) => new OAuth2Error({ From a26ebcb50eb6d7edae35454974178fa19ec4cc02 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:07:56 -0700 Subject: [PATCH 09/11] Give OAuthStartError its verdict fields, and declare the payloads once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/core/api/src/integrations/api.ts | 7 +-- packages/core/api/src/oauth/api.ts | 11 +---- packages/core/sdk/src/index.ts | 4 +- packages/core/sdk/src/integration.ts | 10 +--- packages/core/sdk/src/oauth-client.ts | 56 +++++++++++++++++++---- packages/core/sdk/src/shared.ts | 4 +- packages/plugins/mcp/src/sdk/types.ts | 16 +++---- 7 files changed, 65 insertions(+), 43 deletions(-) diff --git a/packages/core/api/src/integrations/api.ts b/packages/core/api/src/integrations/api.ts index ae2c7abb00..6700c7a314 100644 --- a/packages/core/api/src/integrations/api.ts +++ b/packages/core/api/src/integrations/api.ts @@ -11,6 +11,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; import { + EnterpriseIdentityProviderDescriptorSchema, HealthCheckCandidate, HealthCheckSpec, IntegrationDetectionResult, @@ -18,8 +19,6 @@ import { IntegrationRemovalNotAllowedError, IntegrationSlug, InternalError, - OAuthClientSlug, - Owner, } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- @@ -60,9 +59,7 @@ const OAuthDescriptor = Schema.Struct({ * this integration's identity assertions. Present only when the deployment * declared one — the client names it on `oauth.start` alongside the * assertion it holds. The interactive flow stays available regardless. */ - enterpriseIdentityProvider: Schema.optional( - Schema.Struct({ client: OAuthClientSlug, clientOwner: Owner }), - ), + enterpriseIdentityProvider: Schema.optional(EnterpriseIdentityProviderDescriptorSchema), }); /** A single declared auth method — mirrors the SDK's `AuthMethodDescriptor`. */ diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index e3eb0c7bc3..96e76a26c1 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -18,6 +18,7 @@ import { AuthTemplateSlug, ConnectionAddress, ConnectionName, + EnterpriseManagedStartInputSchema, IntegrationSlug, InternalError, OAuthClientSlug, @@ -29,7 +30,6 @@ import { OAuthState, Owner, ProviderKey, - SubjectTokenTypeSchema, } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- @@ -179,14 +179,7 @@ const StartPayload = Schema.Struct({ * id/secret authenticate at the MCP server's authorization server, while * these name the SECOND registration at the enterprise identity provider and * carry the identity assertion the user already holds from single sign-on. */ - enterprise: Schema.optional( - Schema.Struct({ - idpClient: OAuthClientSlug, - idpClientOwner: Owner, - subjectToken: Schema.String, - subjectTokenType: Schema.optional(SubjectTokenTypeSchema), - }), - ), + enterprise: Schema.optional(EnterpriseManagedStartInputSchema), }); const StartResponse = Schema.Union([ diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 2f874e1cfb..d0486f2acf 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -292,11 +292,13 @@ export { OAuthRegisterDynamicError, OAuthSessionNotFoundError, FIRST_PARTY_OAUTH_CLIENT_PREFIX, - SUBJECT_TOKEN_TYPES, SubjectTokenTypeSchema, DEFAULT_SUBJECT_TOKEN_TYPE, + EnterpriseManagedStartInputSchema, + EnterpriseIdentityProviderDescriptorSchema, type SubjectTokenType, type EnterpriseManagedStartInput, + type EnterpriseIdentityProviderDescriptor, firstPartyOAuthClientSlug, isFirstPartyOAuthClientSlug, type FirstPartyOAuthClientConfig, diff --git a/packages/core/sdk/src/integration.ts b/packages/core/sdk/src/integration.ts index 4c619eb01c..e1ac53b396 100644 --- a/packages/core/sdk/src/integration.ts +++ b/packages/core/sdk/src/integration.ts @@ -1,4 +1,5 @@ -import type { IntegrationSlug, OAuthClientSlug, Owner } from "./ids"; +import type { IntegrationSlug } from "./ids"; +import type { EnterpriseIdentityProviderDescriptor } from "./oauth-client"; /* Core knows only an integration's catalog identity — slug + description + which * plugin (`kind`) owns it. The type-specific shape (openapi auth templates + spec, @@ -73,13 +74,6 @@ export interface AuthMethodOAuthDescriptor { readonly enterpriseIdentityProvider?: EnterpriseIdentityProviderDescriptor; } -/** Which registered OAuth app stands for the enterprise IdP, so a connect - * request can name it. Carries no assertion and no secret — only the pointer. */ -export interface EnterpriseIdentityProviderDescriptor { - readonly client: OAuthClientSlug; - readonly clientOwner: Owner; -} - /** A single declared auth method on an integration's catalog response. */ export interface AuthMethodDescriptor { /** Stable id within the integration (e.g. the auth template slug). */ diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index c4ca773038..2cbbbd9110 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -10,7 +10,7 @@ import { type IntegrationSlug, OAuthClientSlug, OAuthState, - type Owner, + Owner, } from "./ids"; /** RFC 8693 §3 security token type identifiers usable as a `subject_token_type` @@ -18,7 +18,7 @@ import { * draft §4.3 profiles `id_token` and `saml2` for identity assertions and * `refresh_token` for the re-issue path; `access_token` is the RFC 8693 base * type some enterprise IdPs mint their SSO assertion as. */ -export const SUBJECT_TOKEN_TYPES = [ +const SUBJECT_TOKEN_TYPES = [ "urn:ietf:params:oauth:token-type:id_token", "urn:ietf:params:oauth:token-type:saml2", "urn:ietf:params:oauth:token-type:refresh_token", @@ -38,6 +38,24 @@ export type SubjectTokenType = typeof SubjectTokenTypeSchema.Type; export const DEFAULT_SUBJECT_TOKEN_TYPE: SubjectTokenType = "urn:ietf:params:oauth:token-type:id_token"; +/** Which registered OAuth app stands for an integration's enterprise identity + * provider, so a connect request can name it. Carries no assertion and no + * secret — only the pointer. + * + * One declaration for a shape that crosses three boundaries: the integration + * catalog descriptor, the API's integrations response, and the MCP plugin's + * server config all reference THIS schema rather than restating its fields. */ +export const EnterpriseIdentityProviderDescriptorSchema = Schema.Struct({ + client: OAuthClientSlug, + clientOwner: Owner, +}).annotate({ + identifier: "EnterpriseIdentityProviderDescriptor", + description: + "The registered OAuth app that stands for the enterprise identity provider minting an integration's ID-JAGs.", +}); +export type EnterpriseIdentityProviderDescriptor = + typeof EnterpriseIdentityProviderDescriptorSchema.Type; + /* The v2 OAuth surface contracts. OAuth is a credential mechanism, not an * integration type. A client is a registered app; running its flow mints a * Connection. The client is self-contained (carries its own endpoints) and @@ -261,18 +279,26 @@ export interface OAuthStartInput { /** What an enterprise-managed connect needs beyond the ordinary start inputs. * The subject token is supplied by the caller because "where the identity * assertion comes from" is a host concern: a desktop app holds its own SSO - * tokens, a hosted deployment holds the session's. */ -export interface EnterpriseManagedStartInput { + * tokens, a hosted deployment holds the session's. + * + * Declared as a Schema because this shape crosses the HTTP boundary: the API's + * `oauth.start` payload embeds THIS schema rather than restating its fields. */ +export const EnterpriseManagedStartInputSchema = Schema.Struct({ /** `oauth_client` slug of the client's registration at the enterprise IdP. */ - readonly idpClient: OAuthClientSlug; - readonly idpClientOwner: Owner; + idpClient: OAuthClientSlug, + idpClientOwner: Owner, /** The identity assertion from single sign-on with the IdP (an OIDC ID token * by default). Persisted through the credential provider so token renewal * needs no further user interaction. */ - readonly subjectToken: string; + subjectToken: Schema.String, /** RFC 8693 §3 type of `subjectToken`. Defaults to an OIDC ID token. */ - readonly subjectTokenType?: SubjectTokenType; -} + subjectTokenType: Schema.optional(SubjectTokenTypeSchema), +}).annotate({ + identifier: "EnterpriseManagedStartInput", + description: + "The second client registration (at the enterprise identity provider) and the identity assertion an enterprise-managed connect presents.", +}); +export type EnterpriseManagedStartInput = typeof EnterpriseManagedStartInputSchema.Type; export interface OAuthCompleteInput { readonly state: OAuthState; @@ -339,6 +365,18 @@ export interface RegisterDynamicClientInput { export class OAuthStartError extends Schema.TaggedErrorClass()("OAuthStartError", { message: Schema.String, + /** True when an enterprise identity provider declined to authorize this + * connection under administrator policy. A console MUST branch on this + * rather than on the message: blocked-by-admin means the interactive + * per-server flow must NOT be offered as an alternative route, because + * taking it would walk the user around the policy the IdP just enforced. + * Every other start failure leaves that route open. */ + blockedByAdmin: Schema.optional(Schema.Boolean), + /** The authorization server's RFC 6749 §5.2 error code (`invalid_target`, + * `unauthorized_client`, `invalid_grant`, …), when the failure came from a + * token-endpoint refusal. A typed field rather than message text so + * telemetry and support tooling read the verdict structurally. */ + oauthErrorCode: Schema.optional(Schema.String), }) implements UserActionableError { diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index b436f2a672..c0dcfc5de0 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -147,11 +147,13 @@ export { FIRST_PARTY_OAUTH_CLIENT_PREFIX, firstPartyOAuthClientSlug, isFirstPartyOAuthClientSlug, - SUBJECT_TOKEN_TYPES, SubjectTokenTypeSchema, DEFAULT_SUBJECT_TOKEN_TYPE, + EnterpriseManagedStartInputSchema, + EnterpriseIdentityProviderDescriptorSchema, type SubjectTokenType, type EnterpriseManagedStartInput, + type EnterpriseIdentityProviderDescriptor, type FirstPartyOAuthClientConfig, type OAuthGrant, type OAuthAuthentication, diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index 83ca9b7a1e..6b2f5de4fe 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -1,5 +1,5 @@ import { Effect, Option, Schema } from "effect"; -import { OAuthClientSlug, Owner } from "@executor-js/sdk/core"; +import { EnterpriseIdentityProviderDescriptorSchema } from "@executor-js/sdk/core"; import { ApiKeyAuthMethod, ApiKeyAuthTemplate, @@ -70,15 +70,11 @@ export type McpStdioVersionNegotiation = typeof McpStdioVersionNegotiation.Type; * here can never take an ordinary MCP server off the interactive flow. * * It is the POINTER only — no assertion, no secret. The identity assertion is - * supplied per connect request by whoever holds the user's single sign-on. */ -export const McpEnterpriseIdentityProvider = Schema.Struct({ - client: OAuthClientSlug, - clientOwner: Owner, -}).annotate({ - identifier: "McpEnterpriseIdentityProvider", - description: - "The registered OAuth app that stands for the enterprise identity provider minting this server's ID-JAGs.", -}); + * supplied per connect request by whoever holds the user's single sign-on. + * + * The SDK owns the shape: this config, the integration catalog descriptor, and + * the API's integrations response are one wire payload with one declaration. */ +export const McpEnterpriseIdentityProvider = EnterpriseIdentityProviderDescriptorSchema; export type McpEnterpriseIdentityProvider = typeof McpEnterpriseIdentityProvider.Type; export const McpOAuthMethod = Schema.Struct({ From ee677336d5aabc8d53840bac988653df4102a77a Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:08:15 -0700 Subject: [PATCH 10/11] Keep the enterprise-managed taxonomy structural through connect and refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/core/sdk/src/executor.ts | 45 ++---- packages/core/sdk/src/oauth-ema.ts | 32 ++-- packages/core/sdk/src/oauth-service.ts | 207 ++++++++++++++++--------- 3 files changed, 171 insertions(+), 113 deletions(-) diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 050cb5e000..d59328daed 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -172,7 +172,7 @@ import { ENTERPRISE_MANAGED_PROVIDER_STATE_KEY, enterpriseManagedStateFrom, mintEnterpriseManagedAccessToken, - type EnterpriseManagedAuthorizationError, + type EnterpriseManagedMintError, } from "./oauth-ema"; import { connectionIdentifier } from "./connection-name-identifier"; import { annotateToolResultOutcome } from "./tool-result"; @@ -1873,11 +1873,9 @@ export const createExecutor = - // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: see above + /** The rendered message of a typed enterprise-managed failure. */ + const enterpriseManagedMessage = (cause: EnterpriseManagedMintError): string => + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: every EMA error declares `message` as a getter over its own typed fields, so this is a projection of a typed failure, not a read off an unknown throwable cause.message; /** Re-mint an enterprise-managed access token: exchange the stored identity @@ -1986,19 +1984,6 @@ export const createExecutor = Effect.fail(new StorageError({ message: enterpriseManagedMessage(cause), cause })), - // The profile is confirmed at connect and never re-discovered on - // this path, so this arm is unreachable here; surface it as a - // reconnect rather than pretending it cannot happen. - EmaGrantProfileUnsupported: (cause) => - Effect.fail( - new CredentialResolutionError({ - owner, - integration: IntegrationSlug.make(row.integration), - name: ConnectionName.make(row.name), - message: enterpriseManagedMessage(cause), - reauthRequired: true, - }), - ), }), Effect.tapError((error) => Predicate.isTagged(error, "CredentialResolutionError") && error.reauthRequired === true @@ -3173,17 +3158,19 @@ export const createExecutor = { +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, @@ -260,7 +265,7 @@ const exchangeFailure = (cause: OAuth2Error): EnterpriseManagedAuthorizationErro return new EmaUpstreamUnavailable({ step: "token-exchange", detail }); }; -const redemptionFailure = (cause: OAuth2Error): EnterpriseManagedAuthorizationError => { +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 @@ -279,14 +284,12 @@ const redemptionFailure = (cause: OAuth2Error): EnterpriseManagedAuthorizationEr * itself has expired. */ export const mintEnterpriseManagedAccessToken = ( input: EnterpriseManagedAuthorizationInput, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const grant = yield* exchangeSubjectTokenForIdJag({ tokenUrl: input.idp.tokenUrl, - issuerUrl: input.idp.issuerUrl, clientId: input.idp.clientId, clientSecret: input.idp.clientSecret, - clientAuth: input.idp.clientAuth, subjectToken: input.subjectToken, subjectTokenType: input.subjectTokenType ?? DEFAULT_SUBJECT_TOKEN_TYPE, audience: input.resourceAuthorizationServer.issuer, @@ -308,7 +311,6 @@ export const mintEnterpriseManagedAccessToken = ( issuerUrl: input.resourceAuthorizationServer.issuer, clientId: input.resourceAuthorizationServer.clientId, clientSecret: input.resourceAuthorizationServer.clientSecret, - clientAuth: input.resourceAuthorizationServer.clientAuth, assertion: grant.assertion, resource: input.resource, scopes: grantedScopes, @@ -341,7 +343,7 @@ export const runEnterpriseManagedAuthorization = ( >; }, ): Effect.Effect => - Effect.suspend(() => { + Effect.suspend(() => { const metadata = input.authorizationServerMetadata; if (!supportsIdJagGrantProfile(metadata)) { return Effect.fail( diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 10eab623ed..6134b35d4c 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -14,7 +14,7 @@ // redeems the session, exchanges the code, and mints the connection. // --------------------------------------------------------------------------- -import { Duration, Effect, Layer, Option, Schema } from "effect"; +import { Duration, Effect, Layer, Match, Option, Schema } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import { connectionIdentifier } from "./connection-name-identifier"; @@ -53,6 +53,7 @@ import { type OAuthService, type OAuthStartInput, type RegisterDynamicClientInput, + type SubjectTokenType, } from "./oauth-client"; import type { OwnerBinding } from "./plugin"; import type { CredentialProvider } from "./provider"; @@ -67,6 +68,7 @@ import { runEnterpriseManagedAuthorization, type EnterpriseManagedConnectionState, type EnterpriseManagedGrant, + type EnterpriseManagedMintError, } from "./oauth-ema"; import { assertSupportedOAuthEndpointUrl, @@ -119,6 +121,43 @@ export interface MintOAuthConnectionInput { readonly oauthTokenUrl?: string | null; } +/** Project an enterprise-managed mint failure onto the connect boundary, + * KEEPING the taxonomy structural. `EmaPolicyDenied` is the one verdict a + * console must treat differently from every other start failure: it means the + * administrator declined, so re-authenticating cannot help and the ordinary + * per-server flow must not be offered as a way around it. That decision has to + * be readable as a field — a UI cannot branch on a sentence. */ +const startErrorFromEnterpriseManaged = (cause: EnterpriseManagedMintError): OAuthStartError => { + const rendered = (failure: EnterpriseManagedMintError): string => + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: every EMA error declares `message` as a getter over its own typed fields, so this is a projection of a typed failure, not a read off an unknown throwable + failure.message; + return Match.value(cause).pipe( + Match.tag( + "EmaPolicyDenied", + (denied) => + new OAuthStartError({ + message: rendered(denied), + blockedByAdmin: true, + oauthErrorCode: denied.error, + }), + ), + Match.tag( + "EmaRedemptionRejected", + (rejected) => + new OAuthStartError({ + message: rendered(rejected), + ...(rejected.error === undefined ? {} : { oauthErrorCode: rejected.error }), + }), + ), + Match.tag( + "EmaSubjectTokenRejected", + "EmaUpstreamUnavailable", + (failure) => new OAuthStartError({ message: rendered(failure) }), + ), + Match.exhaustive, + ); +}; + /** The OAuth scope policy for a `(integration, template)`. Either the * integration declares the scopes to request (`scopes`, possibly empty — an * empty set requests no scopes), or it declares none and the request scopes @@ -583,6 +622,53 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const capScopes = (scopes: readonly string[]): readonly string[] => dedupeScopes(scopes).slice(0, MAX_DISCOVERED_SCOPES); + // Bound a whole discovery sequence (PRM + up to MAX_DISCOVERY_AUTH_SERVERS AS + // fetches, each with its own request timeout). 30s is larger than a single + // request timeout so it bounds the sequence, not a slow-but-valid request. + const withDiscoverySequenceTimeout = ( + sequence: Effect.Effect, + message: string, + ): Effect.Effect => + sequence.pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(30), + orElse: () => Effect.fail(new OAuthDiscoveryError({ message, cause: "timeout" })), + }), + ); + + /** Probe, in order, the authorization servers a protected resource named, and + * return the first whose RFC 8414 metadata both reads cleanly and satisfies + * `accept`. Any AS we cannot read clean metadata from — unreachable, 404, + * malformed, or issuer-mismatched — contributes nothing and we move on + * (mirroring the dynamic-registration discovery path). We never probe an + * arbitrary URL: only the hosts the resource itself named, already capped by + * the caller because that list is server-controlled. */ + const firstReadableAuthorizationServer = ( + issuers: readonly string[], + accept: (metadata: OAuthAuthorizationServerMetadata) => boolean, + ): Effect.Effect => + Effect.gen(function* () { + const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy, httpClientLayer }; + for (const issuer of issuers) { + const authServer = yield* discoverAuthorizationServerMetadata( + issuer, + discoveryOptions, + ).pipe(Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null))); + if (authServer && accept(authServer.metadata)) return authServer.metadata; + } + return null; + }); + + /** The authorization servers a protected resource names, capped: the list is + * server-controlled and a hostile or buggy server must not be able to make + * us walk an unbounded number of hosts. */ + const authorizationServerIssuersFor = ( + protectedResource: { + readonly metadata: { readonly authorization_servers?: readonly string[] }; + } | null, + ): readonly string[] => + (protectedResource?.metadata.authorization_servers ?? []).slice(0, MAX_DISCOVERY_AUTH_SERVERS); + // Discover the scopes to request when the integration declares none — only // reached for integrations that opt in (MCP-style). The resource's own RFC // 9728 `scopes_supported` is authoritative when present, even when empty (§2 @@ -611,39 +697,18 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // The resource is silent on scopes — read them from the authorization // servers it names, in order. An advertised list is authoritative even - // when empty. Any AS we cannot read clean RFC 8414 metadata from — - // unreachable, 404, malformed, or issuer-mismatched — contributes nothing - // and we move on (mirroring the dynamic-registration discovery path); if - // none advertise scopes we request none and let the AS apply its defaults - // (RFC 8414 metadata is optional, so its absence is not a failure). The - // list is server-controlled, so cap how many of its hosts we probe. - for (const issuer of (protectedResource?.metadata.authorization_servers ?? []).slice( - 0, - MAX_DISCOVERY_AUTH_SERVERS, - )) { - const authServer = yield* discoverAuthorizationServerMetadata( - issuer, - discoveryOptions, - ).pipe(Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null))); - const scopes = authServer?.metadata.scopes_supported; - if (scopes !== undefined) return capScopes(scopes); - } - - return []; - }).pipe( - // Bound the whole sequence (PRM + up to MAX_DISCOVERY_AUTH_SERVERS AS - // fetches, each with its own request timeout). 30s is larger than a single - // request timeout so it bounds the sequence, not a slow-but-valid request. - Effect.timeoutOrElse({ - duration: Duration.seconds(30), - orElse: () => - Effect.fail( - new OAuthDiscoveryError({ - message: "OAuth scope discovery timed out", - cause: "timeout", - }), - ), - }), + // when empty, so "advertises scopes at all" is the acceptance test. If + // none do we request none and let the AS apply its defaults (RFC 8414 + // metadata is optional, so its absence is not a failure). + const authServer = yield* firstReadableAuthorizationServer( + authorizationServerIssuersFor(protectedResource), + (metadata) => metadata.scopes_supported !== undefined, + ); + return authServer?.scopes_supported === undefined + ? [] + : capScopes(authServer.scopes_supported); + }).pipe((sequence) => + withDiscoverySequenceTimeout(sequence, "OAuth scope discovery timed out"), ); /** The RFC 8414 metadata of the authorization server that protects `resource`. @@ -662,25 +727,21 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { "Cannot discover the authorization server: the OAuth app has no resource configured", }); } - const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy, httpClientLayer }; - const protectedResource = yield* discoverProtectedResourceMetadata( - resource, - discoveryOptions, - ); - const issuers = protectedResource?.metadata.authorization_servers ?? []; - for (const issuer of issuers.slice(0, MAX_DISCOVERY_AUTH_SERVERS)) { - const authServer = yield* discoverAuthorizationServerMetadata( - issuer, - discoveryOptions, - ).pipe(Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null))); - if (authServer) return authServer.metadata; - } + const protectedResource = yield* discoverProtectedResourceMetadata(resource, { + endpointUrlPolicy: deps.endpointUrlPolicy, + httpClientLayer, + }); + const issuers = authorizationServerIssuersFor(protectedResource); + const metadata = yield* firstReadableAuthorizationServer(issuers, () => true); + if (metadata) return metadata; return yield* new OAuthDiscoveryError({ message: `No authorization-server metadata found for ${resource}${ issuers.length > 0 ? ` (tried: ${issuers.join(", ")})` : "" }`, }); - }); + }).pipe((sequence) => + withDiscoverySequenceTimeout(sequence, "OAuth authorization-server discovery timed out"), + ); // ----------------------------------------------------------------------- // createClient — write the oauth_client row. @@ -1366,6 +1427,13 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }), ), ); + // Resolve the caller's optional assertion type ONCE: the chain sends it + // and the connection persists it, and those two must not be able to + // disagree about what was presented. + const resolvedEnterprise = { + ...enterprise, + subjectTokenType: enterprise.subjectTokenType ?? DEFAULT_SUBJECT_TOKEN_TYPE, + }; const enterpriseGrant = yield* runEnterpriseManagedAuthorization({ authorizationServerMetadata: metadata, idp: { @@ -1377,27 +1445,24 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { clientId: client.clientId, clientSecret: client.clientSecret, }, - subjectToken: enterprise.subjectToken, - subjectTokenType: enterprise.subjectTokenType ?? DEFAULT_SUBJECT_TOKEN_TYPE, + subjectToken: resolvedEnterprise.subjectToken, + subjectTokenType: resolvedEnterprise.subjectTokenType, resource: client.resource, scopes: requestedScopes, endpointUrlPolicy: deps.endpointUrlPolicy, + // No `httpClientLayer` here, deliberately: like every other token + // request in this service, the ID-JAG chain runs through oauth4webapi + // on the configured `fetch`, not Effect's HttpClient. Only discovery + // speaks HttpClient. Providing the layer here would claim otherwise. fetch, }).pipe( - Effect.provide(httpClientLayer), Effect.map((grant) => ({ supported: true as const, grant })), // Only the unsupported-profile failure is recoverable; every other - // tag reaches the caller as a start error with its own wording. + // tag reaches the caller as a start error carrying its own verdict. Effect.catchTag("EmaGrantProfileUnsupported", () => Effect.succeed({ supported: false as const }), ), - Effect.mapError( - (cause) => - new OAuthStartError({ - // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: every EMA error carries a typed `message` getter - message: cause.message, - }), - ), + Effect.mapError(startErrorFromEnterpriseManaged), ); if (enterpriseGrant.supported) { const connection = yield* mintEnterpriseManagedConnection( @@ -1405,13 +1470,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { client, input.clientOwner, enterpriseGrant.grant, - enterprise, - { - idpClient: String(enterprise.idpClient), - idpClientOwner: enterprise.idpClientOwner, - audience: metadata.issuer, - subjectTokenType: enterprise.subjectTokenType ?? DEFAULT_SUBJECT_TOKEN_TYPE, - }, + resolvedEnterprise, + metadata.issuer, ).pipe( Effect.mapError( (cause) => @@ -1788,8 +1848,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { client: LoadedOAuthClient, clientOwner: Owner, grant: EnterpriseManagedGrant, - enterprise: EnterpriseManagedStartInput, - enterpriseState: EnterpriseManagedConnectionState, + /** The connect request's enterprise inputs with the assertion type already + * resolved — the persisted state records what was actually presented, so + * it must not re-derive a default the chain might have differed on. */ + enterprise: EnterpriseManagedStartInput & { readonly subjectTokenType: SubjectTokenType }, + /** The Resource Authorization Server's issuer identifier, as discovered. */ + audience: string, ): Effect.Effect => Effect.gen(function* () { const provider = deps.defaultWritableProvider(); @@ -1823,7 +1887,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { refreshItemId: subjectTokenItemId, expiresAt: expiresAtFrom(grant.token), oauthScope: grant.scope, - enterpriseManaged: enterpriseState, + enterpriseManaged: { + idpClient: enterprise.idpClient, + idpClientOwner: enterprise.idpClientOwner, + audience, + subjectTokenType: enterprise.subjectTokenType, + }, }); }); From a718ae11291a73f9f72d0460384ad94b87cbf305 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:08:36 -0700 Subject: [PATCH 11/11] Assert the enterprise-managed verdict, not its wording 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. --- .../mcp-enterprise-managed-auth.test.ts | 32 ++++---- .../core/sdk/src/oauth-ema-lifecycle.test.ts | 76 ++++++++++++++++--- packages/core/sdk/src/oauth-ema.test.ts | 50 +++++------- .../sdk/src/testing/id-jag-test-support.ts | 6 ++ .../core/sdk/src/testing/oauth-test-server.ts | 41 ++++++---- 5 files changed, 134 insertions(+), 71 deletions(-) diff --git a/e2e/selfhost/mcp-enterprise-managed-auth.test.ts b/e2e/selfhost/mcp-enterprise-managed-auth.test.ts index c8a6261c63..114a9f40dd 100644 --- a/e2e/selfhost/mcp-enterprise-managed-auth.test.ts +++ b/e2e/selfhost/mcp-enterprise-managed-auth.test.ts @@ -29,8 +29,8 @@ import { randomBytes } from "node:crypto"; import { createServer } from "node:net"; -import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { assert, expect } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { createEmulator, type Emulator, type LedgerEntry } from "@executor-js/emulate"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; @@ -266,7 +266,7 @@ scenario( "declaring an IdP leaves the interactive flow advertised", ).toBe(true); const enterprise = declared?.oauth?.enterpriseIdentityProvider; - if (!enterprise) return; + assert(enterprise, "every connect request below drives off the projected pointer"); // --------------------------------------------------------------- // Phase 1 — empty policy table: the IdP authorizes the exchange. @@ -290,10 +290,10 @@ scenario( // The headline: connected outright. A `redirect` here would mean the // user was sent through per-server consent after all. - expect(connected.status, "the enterprise grant connects with no authorize redirect").toBe( - "connected", + assert( + connected.status === "connected", + "the enterprise grant connects with no authorize redirect", ); - if (connected.status !== "connected") return; expect( connected.connection.oauthScope?.split(" ").sort(), "the connection carries the scopes the IdP granted", @@ -385,14 +385,20 @@ scenario( }) .pipe(Effect.flip); - // Blocked-by-admin reaches the user in those words, carrying the - // IdP's own RFC 8693 §2.2.2 code so support can trace the decision. - expect(blocked._tag).toBe("OAuthStartError"); + // Blocked-by-admin survives the HTTP boundary as STRUCTURE, not as a + // sentence: the fields below are what a console branches on. + assert( + Predicate.isTagged(blocked, "OAuthStartError"), + "a policy denial is a start failure, not a transport or decoding fault", + ); + expect( + blocked.blockedByAdmin, + "the denial reaches the client as a FIELD — a console decides from it whether the interactive flow may be offered, and it cannot decide that from a sentence", + ).toBe(true); expect( - blocked.message, - "the denial is surfaced as an organization decision, not a credential problem", - ).toContain("identity provider did not authorize"); - expect(blocked.message).toContain("invalid_target"); + blocked.oauthErrorCode, + "the IdP's own RFC 8693 §2.2.2 code travels structurally, so support can trace the decision", + ).toBe("invalid_target"); const deniedEntries = yield* ledger(okta); const denied = entryFor(deniedEntries, "okta.oauth.tokenExchange"); diff --git a/packages/core/sdk/src/oauth-ema-lifecycle.test.ts b/packages/core/sdk/src/oauth-ema-lifecycle.test.ts index bae79461bb..73d7dcefb1 100644 --- a/packages/core/sdk/src/oauth-ema-lifecycle.test.ts +++ b/packages/core/sdk/src/oauth-ema-lifecycle.test.ts @@ -9,7 +9,7 @@ // it against a Resource Authorization Server that enforces `exp`. // --------------------------------------------------------------------------- -import { describe, expect, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import { Effect, Predicate } from "effect"; import { @@ -162,11 +162,10 @@ describe("enterprise-managed connections", () => { yield* registerClients(executor.oauth.createClient, servers); const started = yield* executor.oauth.start(startEnterpriseConnect(servers)); - expect( - started.status, + assert( + started.status === "connected", "the identity assertion replaces per-server consent, so there is nothing to redirect to", - ).toBe("connected"); - if (started.status !== "connected") return; + ); expect(started.connection.oauthScope).toBe("mcp.read"); const invoked = (yield* executor.execute(TOOL, {})) as { readonly token: string }; @@ -215,7 +214,18 @@ describe("enterprise-managed connections", () => { yield* servers.idp.revokeAccessToken(servers.subjectToken); const failure = yield* executor.execute(TOOL, {}).pipe(Effect.flip); - expect(String(failure)).toContain("single sign-on"); + assert( + Predicate.isTagged(failure, "CredentialResolutionError"), + "a dead assertion is a credential verdict, not an execution fault", + ); + expect( + failure.reauthRequired, + "only a fresh single sign-on can replace a revoked assertion", + ).toBe(true); + expect( + failure.blockedByAdmin, + "the assertion died; the administrator did not withdraw access", + ).toBeUndefined(); const connections = yield* executor.connections.list(); const connection = connections.find((entry) => String(entry.name) === String(CONNECTION)); @@ -227,6 +237,47 @@ describe("enterprise-managed connections", () => { ), ); + it.effect("reports a policy withdrawn after connect as blocked-by-admin, not as re-auth", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + yield* executor.oauth.start(startEnterpriseConnect(servers)); + yield* executor.execute(TOOL, {}); + + // The administrator revokes access AFTER the connection exists. Renewal + // meets the denial where no user is present, which is the only place + // `blockedByAdmin` is ever produced on a credential failure. + yield* servers.idp.setTokenExchangeDenial({ + error: "access_denied", + errorDescription: "This user is no longer approved for the requested MCP server.", + }); + + const failure = yield* executor.execute(TOOL, {}).pipe(Effect.flip); + assert( + Predicate.isTagged(failure, "CredentialResolutionError"), + "an administrator decision is a credential verdict, not a transport failure that retries", + ); + expect( + failure.blockedByAdmin, + "signing in again cannot help, and the interactive per-server flow must not be offered as a way around the policy", + ).toBe(true); + expect(failure.oauthErrorCode, "the IdP's own §5.2 code travels structurally").toBe( + "access_denied", + ); + + const connections = yield* executor.connections.list(); + const connection = connections.find((entry) => String(entry.name) === String(CONNECTION)); + expect( + connection?.lastHealth?.status, + "the accounts list shows the blocked connection without another probe", + ).toBe("expired"); + }), + ), + ); + it.effect("refuses to fall back to interactive OAuth when the IdP denies the exchange", () => Effect.scoped( Effect.gen(function* () { @@ -244,12 +295,15 @@ describe("enterprise-managed connections", () => { .start(startEnterpriseConnect(servers)) .pipe(Effect.flip); - expect(Predicate.isTagged(failure, "OAuthStartError")).toBe(true); + assert(Predicate.isTagged(failure, "OAuthStartError")); + expect( + failure.blockedByAdmin, + "the console must be able to branch on blocked-by-admin without reading prose: it decides whether the interactive flow may be offered as an alternative", + ).toBe(true); expect( - String(failure.message), - "the user is told their organization declined, not offered a way around it", - ).toContain("identity provider did not authorize"); - expect(String(failure.message)).toContain("unauthorized_client"); + failure.oauthErrorCode, + "the IdP's own verdict travels as a code, so support can trace the decision", + ).toBe("unauthorized_client"); expect( (yield* executor.connections.list()).length, "a denied connect leaves no half-made connection behind", diff --git a/packages/core/sdk/src/oauth-ema.test.ts b/packages/core/sdk/src/oauth-ema.test.ts index a915a3e944..503bed2e21 100644 --- a/packages/core/sdk/src/oauth-ema.test.ts +++ b/packages/core/sdk/src/oauth-ema.test.ts @@ -13,13 +13,15 @@ // let a client bug pass here and fail in production. // --------------------------------------------------------------------------- -import { describe, expect, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import { Effect, Predicate, Ref, Schema } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; -import { supportsIdJagGrantProfile } from "./oauth-discovery"; import { - EmaGrantProfileUnsupported, + OAuthAuthorizationServerMetadataSchema, + supportsIdJagGrantProfile, +} from "./oauth-discovery"; +import { mintEnterpriseManagedAccessToken, runEnterpriseManagedAuthorization, type EnterpriseManagedAuthorizationError, @@ -95,32 +97,28 @@ const chainInput = (fixture: EnterpriseFixture, scopes: readonly string[]) => ({ scopes, }); -/** Fetch the resource server's RFC 8414 metadata the way the connect path does, - * so the profile-detection assertions run against a real document. */ +const decodeMetadata = Schema.decodeUnknownEffect(OAuthAuthorizationServerMetadataSchema); + +/** Fetch the resource server's RFC 8414 metadata the way the connect path does + * and decode it through the PRODUCTION schema, so the profile-detection + * assertions run against a real document parsed by the real decoder. A local + * restatement would pass even if the production schema silently dropped + * `authorization_grant_profiles_supported` — the field this whole gate reads. */ const resourceMetadata = (fixture: EnterpriseFixture) => Effect.gen(function* () { const response = yield* Effect.promise(() => // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: reads the fixture's metadata document exactly as the connect path's discovery does globalThis.fetch(`${fixture.resource.issuerUrl}/.well-known/oauth-authorization-server`), ); - return yield* Effect.promise(() => response.json() as Promise); + return yield* decodeMetadata(yield* Effect.promise((): Promise => response.json())); }); -const AuthorizationServerMetadata = Schema.Struct({ - issuer: Schema.String, - authorization_endpoint: Schema.String, - token_endpoint: Schema.String, - grant_types_supported: Schema.optional(Schema.Array(Schema.String)), - authorization_grant_profiles_supported: Schema.optional(Schema.Array(Schema.String)), -}); -const decodeMetadata = Schema.decodeUnknownSync(AuthorizationServerMetadata); - describe("enterprise-managed authorization: the ID-JAG chain", () => { it.effect("mints an MCP access token from an enterprise identity assertion", () => Effect.scoped( Effect.gen(function* () { const fixture = yield* enterpriseFixture(); - const metadata = decodeMetadata(yield* resourceMetadata(fixture)); + const metadata = yield* resourceMetadata(fixture); expect( supportsIdJagGrantProfile(metadata), @@ -221,7 +219,7 @@ describe("enterprise-managed authorization: failure taxonomy", () => { Effect.scoped( Effect.gen(function* () { const fixture = yield* enterpriseFixture({ resourceAdvertisesProfile: false }); - const metadata = decodeMetadata(yield* resourceMetadata(fixture)); + const metadata = yield* resourceMetadata(fixture); expect(supportsIdJagGrantProfile(metadata)).toBe(false); const error = yield* runEnterpriseManagedAuthorization({ @@ -255,7 +253,7 @@ describe("enterprise-managed authorization: failure taxonomy", () => { }, }, }); - const metadata = decodeMetadata(yield* resourceMetadata(fixture)); + const metadata = yield* resourceMetadata(fixture); const error: EnterpriseManagedAuthorizationError = yield* runEnterpriseManagedAuthorization( { ...chainInput(fixture, ["mcp.read"]), @@ -264,11 +262,10 @@ describe("enterprise-managed authorization: failure taxonomy", () => { }, ).pipe(Effect.flip); - expect( + assert( Predicate.isTagged(error, "EmaPolicyDenied"), "offering interactive OAuth here would route the user around enterprise policy, so this tag is NOT the one the connect path catches", - ).toBe(true); - if (!Predicate.isTagged(error, "EmaPolicyDenied")) return; + ); expect(error.error).toBe("unauthorized_client"); expect( (yield* fixture.resource.requests).some((entry) => entry.path === "/token"), @@ -503,14 +500,3 @@ describe("enterprise-managed authorization: token exchange response contract", ( ), ); }); - -describe("EmaGrantProfileUnsupported", () => { - it("names the profile the server failed to advertise", () => { - const error = new EmaGrantProfileUnsupported({ - issuer: "https://auth.example", - advertised: ["urn:example:other"], - }); - expect(error.message).toContain("urn:ietf:params:oauth:grant-profile:id-jag"); - expect(error.message).toContain("urn:example:other"); - }); -}); diff --git a/packages/core/sdk/src/testing/id-jag-test-support.ts b/packages/core/sdk/src/testing/id-jag-test-support.ts index 5f1109cec9..7534e4c110 100644 --- a/packages/core/sdk/src/testing/id-jag-test-support.ts +++ b/packages/core/sdk/src/testing/id-jag-test-support.ts @@ -25,6 +25,12 @@ import { } from "node:crypto"; import { Option, Schema } from "effect"; +// The `_URN` suffix marks these as INDEPENDENT literals, deliberately not +// imported from the production constants they mirror. This is a conformance +// fixture: it has to be able to disagree with the client under test. Sharing a +// constant would make a typo in the URN invisible — client and server would +// agree on the same wrong string and the suite would still pass. + /** draft §3.1 — the media type an ID-JAG MUST carry in its JWT header. */ export const ID_JAG_HEADER_TYP = "oauth-id-jag+jwt"; diff --git a/packages/core/sdk/src/testing/oauth-test-server.ts b/packages/core/sdk/src/testing/oauth-test-server.ts index 8f30acf8dc..c6a0d73ee4 100644 --- a/packages/core/sdk/src/testing/oauth-test-server.ts +++ b/packages/core/sdk/src/testing/oauth-test-server.ts @@ -152,6 +152,13 @@ export interface OAuthTestServerShape { * fixture rejects the exchange afterwards exactly as it would for an expired * or revoked assertion. */ readonly revokeAccessToken: (token: string) => Effect.Effect; + /** Start (or stop) refusing every RFC 8693 exchange with this §5.2 error. + * `enterpriseIdp.denyExchangeWith` sets the same policy up front; this drives + * the case that only exists over time — an administrator withdrawing access + * AFTER a connection was made, which the credential-refresh path meets. */ + readonly setTokenExchangeDenial: ( + denial: { readonly error: string; readonly errorDescription: string } | null, + ) => Effect.Effect; readonly acceptsAuthorizationHeader: ( authorization: string | null | undefined, ) => Effect.Effect; @@ -482,26 +489,26 @@ const decodeJwksUriMetadata = Schema.decodeUnknownOption(JwksUriMetadata); /** Resolve a trusted IdP's signing keys the way a Resource Authorization Server * does: read its RFC 8414 metadata, follow `jwks_uri`, fetch the key set. Any - * failure yields null, which the caller reports as `invalid_grant` — the + * failure yields `None`, which the caller reports as `invalid_grant` — the * fixture never falls back to trusting an unverified assertion. */ -const fetchTrustedIdpJwks = (issuer: string): Effect.Effect => +const fetchTrustedIdpJwks = (issuer: string): Effect.Effect> => Effect.gen(function* () { const metadataUrl = `${issuer.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`; const metadataResponse = yield* executeOAuthHttp( HttpClientRequest.get(metadataUrl), metadataUrl, ); - if (metadataResponse.status !== 200) return null; + if (metadataResponse.status !== 200) return Option.none(); const metadata = yield* metadataResponse.json; const decoded = decodeJwksUriMetadata(metadata); - if (Option.isNone(decoded)) return null; + if (Option.isNone(decoded)) return Option.none(); const jwksResponse = yield* executeOAuthHttp( HttpClientRequest.get(decoded.value.jwks_uri), decoded.value.jwks_uri, ); - if (jwksResponse.status !== 200) return null; - return yield* jwksResponse.json; - }).pipe(Effect.catch(() => Effect.succeed(null))); + if (jwksResponse.status !== 200) return Option.none(); + return Option.some(yield* jwksResponse.json); + }).pipe(Effect.catch(() => Effect.succeed(Option.none()))); /** Parse the `scope` query param from an authorize URL into an ordered list * (empty when the parameter is absent or blank). */ @@ -559,6 +566,12 @@ export const serveOAuthTestServer = ( // pay for it otherwise. const idJagKey = options.enterpriseIdp ? createIdJagSigningKey() : null; const idJagLifetimeSeconds = options.enterpriseIdp?.idJagExpiresInSeconds ?? 300; + // Policy is state, not configuration: an administrator can withdraw access + // between one exchange and the next, and the fixture has to be able to say so. + const tokenExchangeDenial = yield* Ref.make<{ + readonly error: string; + readonly errorDescription: string; + } | null>(options.enterpriseIdp?.denyExchangeWith ?? null); let issuerUrl = ""; const server = yield* serveOAuthTestHttpApp((request) => @@ -890,12 +903,9 @@ export const serveOAuthTestServer = ( // Policy is evaluated BEFORE the subject token, so a denial cannot // be mistaken for a credential problem by a client that only looks // at the first failing check. - if (idp.denyExchangeWith) { - return oauthError( - 400, - idp.denyExchangeWith.error, - idp.denyExchangeWith.errorDescription, - ); + const denial = yield* Ref.get(tokenExchangeDenial); + if (denial) { + return oauthError(400, denial.error, denial.errorDescription); } const subjectAccepted = yield* Ref.get(issuedAccessTokens).pipe( Effect.map((tokens) => tokens.has(subjectToken)), @@ -957,7 +967,7 @@ export const serveOAuthTestServer = ( return oauthError(400, "invalid_request", "assertion is required"); } const jwks = yield* fetchTrustedIdpJwks(resourceServer.trustedIdpIssuer); - if (jwks === null) { + if (Option.isNone(jwks)) { return oauthError( 400, "invalid_grant", @@ -967,7 +977,7 @@ export const serveOAuthTestServer = ( const verified = verifyIdJag({ assertion, trustedIssuer: resourceServer.trustedIdpIssuer, - jwks, + jwks: jwks.value, audience: currentIssuerUrl, authenticatedClientId: clientId, }); @@ -1059,6 +1069,7 @@ export const serveOAuthTestServer = ( next.delete(token); return next; }), + setTokenExchangeDenial: (denial) => Ref.set(tokenExchangeDenial, denial), acceptsAuthorizationHeader: (authorization) => { const token = authorization?.replace(/^Bearer\s+/i, ""); return token