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..114a9f40dd --- /dev/null +++ b/e2e/selfhost/mcp-enterprise-managed-auth.test.ts @@ -0,0 +1,443 @@ +// 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 { 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"; +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; + assert(enterprise, "every connect request below drives off the projected pointer"); + + // --------------------------------------------------------------- + // 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. + assert( + connected.status === "connected", + "the enterprise grant connects with no authorize redirect", + ); + 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 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.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"); + 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); + }), + ); + }), + ), +); 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..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, @@ -54,6 +55,11 @@ 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(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 6a54d46426..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, @@ -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,12 @@ 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(EnterpriseManagedStartInputSchema), }); 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, + }); + }); + + /** 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 + * 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( + // 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: enterpriseManagedMessage(cause), + reauthRequired: true, + blockedByAdmin: true, + oauthErrorCode: cause.error, + }), + ), + EmaSubjectTokenRejected: (cause) => + Effect.fail( + new CredentialResolutionError({ + owner, + integration: IntegrationSlug.make(row.integration), + name: ConnectionName.make(row.name), + message: enterpriseManagedMessage(cause), + reauthRequired: true, + }), + ), + EmaRedemptionRejected: (cause) => + Effect.fail( + new CredentialResolutionError({ + owner, + integration: IntegrationSlug.make(row.integration), + name: ConnectionName.make(row.name), + message: enterpriseManagedMessage(cause), + reauthRequired: cause.error === "invalid_grant", + ...(cause.error === undefined ? {} : { oauthErrorCode: cause.error }), + }), + ), + EmaUpstreamUnavailable: (cause) => + Effect.fail(new StorageError({ message: enterpriseManagedMessage(cause), cause })), + }), + 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 +2092,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 +3155,22 @@ 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 +3226,7 @@ export const createExecutor = 0 - ? { missingOAuthScopes: input.missingOAuthScopes } - : null, + provider_state: providerState, created_at: now, updated_at: now, }); @@ -3082,10 +3260,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..d0486f2acf 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -292,6 +292,13 @@ export { OAuthRegisterDynamicError, OAuthSessionNotFoundError, FIRST_PARTY_OAUTH_CLIENT_PREFIX, + 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 df1ea50876..e1ac53b396 100644 --- a/packages/core/sdk/src/integration.ts +++ b/packages/core/sdk/src/integration.ts @@ -1,4 +1,5 @@ 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, @@ -64,6 +65,13 @@ 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; } /** A single declared auth method on an integration's catalog response. */ diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 5b1800e1b6..2cbbbd9110 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -10,9 +10,52 @@ import { type IntegrationSlug, OAuthClientSlug, OAuthState, - type Owner, + 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. */ +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"; + +/** 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 @@ -23,7 +66,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,8 +269,37 @@ 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. + * + * 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. */ + 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. */ + subjectToken: Schema.String, + /** RFC 8693 §3 type of `subjectToken`. Defaults to an OIDC ID token. */ + 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; readonly code: string; @@ -287,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/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-lifecycle.test.ts b/packages/core/sdk/src/oauth-ema-lifecycle.test.ts new file mode 100644 index 0000000000..73d7dcefb1 --- /dev/null +++ b/packages/core/sdk/src/oauth-ema-lifecycle.test.ts @@ -0,0 +1,336 @@ +// --------------------------------------------------------------------------- +// 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 { assert, describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate } 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)); + assert( + started.status === "connected", + "the identity assertion replaces per-server consent, so there is nothing to redirect to", + ); + 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); + 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)); + expect( + connection?.lastHealth?.status, + "a dead assertion is recorded so the accounts list shows it without a probe", + ).toBe("expired"); + }), + ), + ); + + 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* () { + 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); + + 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( + 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", + ).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 new file mode 100644 index 0000000000..503bed2e21 --- /dev/null +++ b/packages/core/sdk/src/oauth-ema.test.ts @@ -0,0 +1,502 @@ +// 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). +// +// 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 { assert, describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate, Ref, Schema } from "effect"; +import { HttpServerResponse } from "effect/unstable/http"; + +import { + OAuthAuthorizationServerMetadataSchema, + supportsIdJagGrantProfile, +} from "./oauth-discovery"; +import { + mintEnterpriseManagedAccessToken, + 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.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 } satisfies EnterpriseFixture; + }); + +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, +}); + +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* decodeMetadata(yield* Effect.promise((): Promise => response.json())); + }); + +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 = 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 = 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( + 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")), + "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 = yield* resourceMetadata(fixture); + const error: EnterpriseManagedAuthorizationError = yield* runEnterpriseManagedAuthorization( + { + ...chainInput(fixture, ["mcp.read"]), + authorizationServerMetadata: metadata, + resourceAuthorizationServer: { clientId: CLIENT_AT_RESOURCE }, + }, + ).pipe(Effect.flip); + + 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", + ); + 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", + ).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( + Predicate.isTagged(error, "EmaSubjectTokenRejected"), + "a dead assertion needs a fresh single sign-on, not the interactive per-server flow", + ).toBe(true); + }), + ), + ); + + 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(Predicate.isTagged(error, "EmaRedemptionRejected")).toBe(true); + 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(Predicate.isTagged(error, "EmaRedemptionRejected")).toBe(true); + 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(Predicate.isTagged(error, "EmaRedemptionRejected")).toBe(true); + 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(() => + // 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}` }, + }), + ); + 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"); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/oauth-ema.ts b/packages/core/sdk/src/oauth-ema.ts new file mode 100644 index 0000000000..a485db49a8 --- /dev/null +++ b/packages/core/sdk/src/oauth-ema.ts @@ -0,0 +1,364 @@ +// --------------------------------------------------------------------------- +// 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 { OAuthClientSlug, Owner } from "./ids"; +import { + DEFAULT_SUBJECT_TOKEN_TYPE, + SubjectTokenTypeSchema, + type SubjectTokenType, +} from "./oauth-client"; +import { + ID_JAG_GRANT_PROFILE, + supportsIdJagGrantProfile, + type OAuthAuthorizationServerMetadata, +} from "./oauth-discovery"; +import { + exchangeSubjectTokenForIdJag, + redeemIdJagAssertion, + type OAuth2Error, + type OAuth2TokenResponse, + type OAuthEndpointUrlPolicy, +} from "./oauth-helpers"; + +// --------------------------------------------------------------------------- +// Persisted connection state +// +// Everything the renewal path needs that is NOT already a column on the +// connection or its `oauth_client` row. The `oauth_client` supplies the client's +// registration at the Resource Authorization Server (id/secret/token URL/ +// resource); the connection supplies the granted scope and the identity +// assertion (stored in the same credential-provider item a refresh token would +// occupy — it plays exactly that role here). What remains is the pointer to the +// IdP registration and the discovered `audience`, which cannot be derived from +// either. +// --------------------------------------------------------------------------- + +export const EnterpriseManagedConnectionStateSchema = Schema.Struct({ + /** `oauth_client` slug of the client's registration at the enterprise IdP. */ + idpClient: OAuthClientSlug, + idpClientOwner: Owner, + /** The Resource Authorization Server's issuer identifier, as discovered from + * its RFC 8414 metadata when the connection was made. */ + audience: Schema.String, + subjectTokenType: SubjectTokenTypeSchema, +}).annotate({ + identifier: "EnterpriseManagedConnectionState", + description: + "Enterprise-managed authorization wiring persisted on a connection: which IdP registration mints its ID-JAG, and the audience that ID-JAG must name.", +}); +export type EnterpriseManagedConnectionState = typeof EnterpriseManagedConnectionStateSchema.Type; + +/** The key `EnterpriseManagedConnectionState` occupies inside a connection's + * `provider_state` JSON, alongside the other core-owned keys. */ +export const ENTERPRISE_MANAGED_PROVIDER_STATE_KEY = "enterpriseManaged"; + +const decodeEnterpriseManagedProviderState = Schema.decodeUnknownOption( + Schema.Struct({ + [ENTERPRISE_MANAGED_PROVIDER_STATE_KEY]: EnterpriseManagedConnectionStateSchema, + }), +); + +/** Read the enterprise-managed wiring off a connection's decoded + * `provider_state`, or null when the connection is not enterprise-managed. + * A malformed entry reads as absent — the refresh path then fails loudly with + * "not enterprise-managed" rather than half-running the chain on fragments. */ +export const enterpriseManagedStateFrom = ( + providerState: unknown, +): EnterpriseManagedConnectionState | null => + Option.match(decodeEnterpriseManagedProviderState(providerState), { + onNone: () => null, + onSome: (decoded) => decoded[ENTERPRISE_MANAGED_PROVIDER_STATE_KEY], + }); + +// --------------------------------------------------------------------------- +// Errors +// +// The distinctions here drive product behavior, so they are separate tags +// rather than one error carrying a code: +// +// - `EmaGrantProfileUnsupported` is the ONLY failure that permits falling +// back to the ordinary interactive per-server OAuth flow. The server simply +// does not implement the profile. +// - `EmaPolicyDenied` and `EmaSubjectTokenRejected` MUST NOT fall back. +// Falling back would let a user route around the enterprise policy the IdP +// just enforced, which is precisely the control this profile exists to +// provide. +// --------------------------------------------------------------------------- + +/** The Resource Authorization Server does not advertise the ID-JAG grant + * profile in its RFC 8414 metadata (draft §7.2). Enterprise-managed + * authorization is not available for this server; the caller MAY fall back to + * the ordinary interactive authorization-code flow. */ +export class EmaGrantProfileUnsupported extends Data.TaggedError("EmaGrantProfileUnsupported")<{ + readonly issuer: string; + readonly advertised: readonly string[]; +}> { + override get message(): string { + return `The authorization server ${this.issuer} does not advertise ${ID_JAG_GRANT_PROFILE}${ + this.advertised.length > 0 ? ` (advertised: ${this.advertised.join(", ")})` : "" + }.`; + } +} + +/** The enterprise IdP refused to mint an ID-JAG for this client, user, resource + * or scope set. This is an administrator decision, not a credential problem: + * the user cannot fix it by signing in again, and the client MUST NOT offer + * the interactive per-server flow as an alternative route. Surface it as + * blocked-by-admin and stop. */ +export class EmaPolicyDenied extends Data.TaggedError("EmaPolicyDenied")<{ + /** The IdP's RFC 6749 §5.2 error code (`unauthorized_client`, `access_denied`, + * `invalid_target`, `invalid_scope`, `invalid_client`, …). */ + readonly error: string; + readonly detail: string; +}> { + override get message(): string { + return `Your organization's identity provider did not authorize this MCP server (${this.error}): ${this.detail}`; + } +} + +/** The IdP rejected the identity assertion itself (RFC 6749 `invalid_grant`): + * expired, revoked, or issued for a different client. The user must sign in + * with the enterprise IdP again to obtain a fresh assertion. */ +export class EmaSubjectTokenRejected extends Data.TaggedError("EmaSubjectTokenRejected")<{ + readonly detail: string; +}> { + override get message(): string { + return `The enterprise identity assertion was rejected and a new single sign-on is required: ${this.detail}`; + } +} + +/** The Resource Authorization Server refused the ID-JAG (draft §4.4.1): wrong + * `typ`, an `aud` naming a different authorization server, a `client_id` claim + * that does not match the authenticated client, a bad signature, or expiry. */ +export class EmaRedemptionRejected extends Data.TaggedError("EmaRedemptionRejected")<{ + /** The Resource Authorization Server's RFC 6749 §5.2 code, when it returned one. */ + readonly error: string | undefined; + readonly detail: string; +}> { + override get message(): string { + return `The MCP server's authorization server rejected the identity assertion grant${ + this.error === undefined ? "" : ` (${this.error})` + }: ${this.detail}`; + } +} + +/** A token endpoint could not be reached, timed out, or answered with something + * that is not an OAuth response at all. Carries no authorization verdict, so + * it is retryable: the next attempt may well succeed. */ +export class EmaUpstreamUnavailable extends Data.TaggedError("EmaUpstreamUnavailable")<{ + readonly step: "token-exchange" | "redemption"; + readonly detail: string; +}> { + override get message(): string { + return `The enterprise-managed authorization ${this.step} request failed: ${this.detail}`; + } +} + +/** What running the two-step grant can fail with. Notably NOT + * `EmaGrantProfileUnsupported`: minting never inspects metadata, so it cannot + * reach that verdict, and callers of `mintEnterpriseManagedAccessToken` must + * not be made to write an arm for a case that cannot occur. */ +export type EnterpriseManagedMintError = + | EmaPolicyDenied + | EmaSubjectTokenRejected + | EmaRedemptionRejected + | EmaUpstreamUnavailable; + +/** Everything `runEnterpriseManagedAuthorization` can fail with: the mint + * failures plus the one discovery verdict only it can produce. */ +export type EnterpriseManagedAuthorizationError = + | EnterpriseManagedMintError + | EmaGrantProfileUnsupported; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/** The client's registration at the enterprise IdP — the relationship that + * authenticates the token-exchange request (draft §5: this is a DIFFERENT + * registration from the one at the Resource Authorization Server). */ +export interface EnterpriseIdentityProvider { + /** The IdP's token endpoint, where the RFC 8693 exchange is POSTed. */ + readonly tokenUrl: string; + readonly clientId: string; + /** Empty or null for a public client the IdP does not authenticate. */ + readonly clientSecret?: string | null; +} + +/** The client's registration at the MCP server's Resource Authorization Server + * — the credentials that authenticate the ID-JAG redemption (draft §4.4). */ +export interface ResourceAuthorizationServerClient { + readonly tokenUrl: string; + /** The Resource Authorization Server's RFC 8414 issuer identifier. Sent as + * the exchange's `audience` and validated by the server as the ID-JAG's + * `aud` claim, which is what stops an ID-JAG minted for one server from + * being replayed at another (draft §4.4.1). */ + readonly issuer: string; + readonly clientId: string; + readonly clientSecret?: string | null; +} + +export interface EnterpriseManagedAuthorizationInput { + readonly idp: EnterpriseIdentityProvider; + readonly resourceAuthorizationServer: ResourceAuthorizationServerClient; + /** The identity assertion obtained from single sign-on with the IdP. */ + readonly subjectToken: string; + readonly subjectTokenType?: SubjectTokenType; + /** RFC 9728 resource identifier of the MCP server (EMA profile §4). */ + readonly resource?: string | null; + readonly scopes?: readonly string[]; + readonly timeoutMs?: number; + readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; + readonly fetch?: typeof globalThis.fetch; +} + +/** An access token minted through the ID-JAG chain, plus the scope the Resource + * Authorization Server actually granted. There is no refresh token by design + * (draft §4.4.3): renewal re-runs the chain. */ +export interface EnterpriseManagedGrant { + readonly token: OAuth2TokenResponse; + /** Granted scope as echoed by the Resource Authorization Server, falling back + * to what the IdP recorded in the ID-JAG. Null when neither said. */ + readonly scope: string | null; +} + +// --------------------------------------------------------------------------- +// The chain +// --------------------------------------------------------------------------- + +const exchangeFailure = (cause: OAuth2Error): EnterpriseManagedMintError => { + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuth2Error declares `message` as a field; this is a typed failure, not an unknown throwable + const detail = cause.message; + // RFC 6749 §5.2: `invalid_grant` is the code for a grant that is invalid, + // expired or revoked — here, the identity assertion the client presented. Any + // OTHER definitive code is the IdP declining to authorize this client for + // this target, which is an administrator decision. + if (cause.error === "invalid_grant") { + return new EmaSubjectTokenRejected({ detail }); + } + if (cause.error !== undefined) { + return new EmaPolicyDenied({ error: cause.error, detail }); + } + return new EmaUpstreamUnavailable({ step: "token-exchange", detail }); +}; + +const redemptionFailure = (cause: OAuth2Error): EnterpriseManagedMintError => { + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: see `exchangeFailure` above + const detail = cause.message; + return cause.error === undefined + ? new EmaUpstreamUnavailable({ step: "redemption", detail }) + : new EmaRedemptionRejected({ error: cause.error, detail }); +}; + +/** Run the two-step grant: exchange the identity assertion for an ID-JAG at the + * IdP, then redeem the ID-JAG at the Resource Authorization Server. + * + * The ID-JAG is deliberately NOT retained. Draft §4.4.3 lets a client re-submit + * a still-valid ID-JAG when only the access token expired, but that saves one + * round trip at the cost of persisting a second bearer-equivalent credential + * whose expiry the client would then have to track. Re-running the exchange is + * the simpler correct behavior and is what §4.4.3 prescribes once the ID-JAG + * itself has expired. */ +export const mintEnterpriseManagedAccessToken = ( + input: EnterpriseManagedAuthorizationInput, +): Effect.Effect => + Effect.gen(function* () { + const grant = yield* exchangeSubjectTokenForIdJag({ + tokenUrl: input.idp.tokenUrl, + clientId: input.idp.clientId, + clientSecret: input.idp.clientSecret, + subjectToken: input.subjectToken, + subjectTokenType: input.subjectTokenType ?? DEFAULT_SUBJECT_TOKEN_TYPE, + audience: input.resourceAuthorizationServer.issuer, + resource: input.resource, + scopes: input.scopes, + timeoutMs: input.timeoutMs, + endpointUrlPolicy: input.endpointUrlPolicy, + fetch: input.fetch, + }).pipe(Effect.mapError(exchangeFailure)); + + // Re-request only what the IdP granted. Policy MAY narrow the set (§4.3.3), + // and asking the Resource Authorization Server for more than the ID-JAG + // carries would be asking it to exceed the enterprise's own decision. + const grantedScopes = + grant.scope === undefined ? input.scopes : grant.scope.split(/\s+/).filter(Boolean); + + const token = yield* redeemIdJagAssertion({ + tokenUrl: input.resourceAuthorizationServer.tokenUrl, + issuerUrl: input.resourceAuthorizationServer.issuer, + clientId: input.resourceAuthorizationServer.clientId, + clientSecret: input.resourceAuthorizationServer.clientSecret, + assertion: grant.assertion, + resource: input.resource, + scopes: grantedScopes, + timeoutMs: input.timeoutMs, + endpointUrlPolicy: input.endpointUrlPolicy, + fetch: input.fetch, + }).pipe(Effect.mapError(redemptionFailure)); + + return { + token, + scope: token.scope ?? grant.scope ?? null, + } satisfies EnterpriseManagedGrant; + }).pipe( + Effect.withSpan("executor.oauth.enterprise_managed", { + attributes: { "executor.oauth.has_resource": input.resource != null }, + }), + ); + +/** Detect the profile on the target's discovered metadata, then run the chain. + * The connect path uses this; the credential-refresh path calls + * `mintEnterpriseManagedAccessToken` directly, because the profile was already + * confirmed when the connection was made and re-discovering it on every token + * renewal would add a round trip that can only ever confirm what is stored. */ +export const runEnterpriseManagedAuthorization = ( + input: Omit & { + 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..68622f2546 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; } @@ -308,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; @@ -340,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); @@ -369,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) { @@ -396,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, }); }); @@ -428,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 @@ -439,7 +533,12 @@ const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect => { - 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,]+/) @@ -686,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 }; } @@ -954,6 +1040,226 @@ export const refreshAccessToken = ( }), ); +// --------------------------------------------------------------------------- +// RFC 8693 token exchange → Identity Assertion JWT Authorization Grant +// +// The IdP's response is NOT an OAuth access-token response: `token_type` is the +// `N_A` sentinel (id-jag draft §4.3.4), which `oauth4webapi`'s response +// processors reject outright. So the request goes out through the library's +// grant-agnostic `genericTokenEndpointRequest` and the body is parsed here, +// against the exact shape the draft specifies. +// --------------------------------------------------------------------------- + +const IdJagResponseSchema = Schema.Struct({ + access_token: Schema.String, + issued_token_type: Schema.String, + token_type: Schema.String, + expires_in: Schema.optional(Schema.Number), + scope: Schema.optional(Schema.String), +}).annotate({ identifier: "IdJagTokenExchangeResponse" }); +const decodeIdJagResponse = Schema.decodeUnknownEffect(IdJagResponseSchema); + +/** An Identity Assertion JWT Authorization Grant as returned by the IdP's token + * exchange (id-jag draft §4.3.4). `assertion` is the JWT itself — carried in + * the response's `access_token` field "for historical reasons", per the draft, + * and renamed here so no caller mistakes it for an access token. */ +export type IdJagGrant = { + readonly assertion: string; + /** Granted scopes echoed by the IdP. Absent when the IdP granted exactly what + * was requested; policy MAY narrow the set (§4.3.3). */ + readonly scope?: string; + readonly expiresIn?: number; +}; + +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"); + } + + // 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({ + 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 // --------------------------------------------------------------------------- diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 8ec30c679a..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"; @@ -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, @@ -51,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"; @@ -61,6 +64,12 @@ import { registerDynamicClient as registerDynamicClientDcr, type OAuthAuthorizationServerMetadata, } from "./oauth-discovery"; +import { + runEnterpriseManagedAuthorization, + type EnterpriseManagedConnectionState, + type EnterpriseManagedGrant, + type EnterpriseManagedMintError, +} from "./oauth-ema"; import { assertSupportedOAuthEndpointUrl, buildAuthorizationUrl, @@ -102,12 +111,53 @@ 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. */ 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 @@ -309,7 +359,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, @@ -570,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 @@ -598,39 +697,50 @@ 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); - } + // 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"), + ); - 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", - }), - ), - }), + /** 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 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"), ); // ----------------------------------------------------------------------- @@ -1288,6 +1398,96 @@ 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}`, + }), + ), + ); + // 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: { + tokenUrl: idpClient.tokenUrl, + clientId: idpClient.clientId, + clientSecret: idpClient.clientSecret, + }, + resourceAuthorizationServer: { + clientId: client.clientId, + clientSecret: client.clientSecret, + }, + 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.map((grant) => ({ supported: true as const, grant })), + // Only the unsupported-profile failure is recoverable; every other + // tag reaches the caller as a start error carrying its own verdict. + Effect.catchTag("EmaGrantProfileUnsupported", () => + Effect.succeed({ supported: false as const }), + ), + Effect.mapError(startErrorFromEnterpriseManaged), + ); + if (enterpriseGrant.supported) { + const connection = yield* mintEnterpriseManagedConnection( + { ...input, name }, + client, + input.clientOwner, + enterpriseGrant.grant, + resolvedEnterprise, + metadata.issuer, + ).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 +1832,70 @@ 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, + /** 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(); + 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: { + idpClient: enterprise.idpClient, + idpClientOwner: enterprise.idpClientOwner, + audience, + subjectTokenType: enterprise.subjectTokenType, + }, + }); + }); + 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..c0dcfc5de0 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -147,6 +147,13 @@ export { FIRST_PARTY_OAUTH_CLIENT_PREFIX, firstPartyOAuthClientSlug, isFirstPartyOAuthClientSlug, + SubjectTokenTypeSchema, + DEFAULT_SUBJECT_TOKEN_TYPE, + EnterpriseManagedStartInputSchema, + EnterpriseIdentityProviderDescriptorSchema, + type SubjectTokenType, + type EnterpriseManagedStartInput, + type EnterpriseIdentityProviderDescriptor, type FirstPartyOAuthClientConfig, type OAuthGrant, type OAuthAuthentication, 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..7534e4c110 --- /dev/null +++ b/packages/core/sdk/src/testing/id-jag-test-support.ts @@ -0,0 +1,251 @@ +// --------------------------------------------------------------------------- +// 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"; + +// 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"; + +/** 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..c6a0d73ee4 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,18 @@ 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; + /** 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; @@ -414,6 +474,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 `None`, 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 Option.none(); + const metadata = yield* metadataResponse.json; + const decoded = decodeJwksUriMetadata(metadata); + 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 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). */ export const scopesFromAuthorizeUrl = (authorizationUrl: string): readonly string[] => { @@ -465,6 +561,18 @@ 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; + // 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) => Effect.gen(function* () { @@ -496,6 +604,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 +618,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 +634,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 +860,153 @@ 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. + 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)), + ); + 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 (Option.isNone(jwks)) { + return oauthError( + 400, + "invalid_grant", + `The JWKS of ${resourceServer.trustedIdpIssuer} could not be retrieved`, + ); + } + const verified = verifyIdJag({ + assertion, + trustedIssuer: resourceServer.trustedIdpIssuer, + jwks: jwks.value, + 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 +1063,13 @@ 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; + }), + setTokenExchangeDenial: (denial) => Ref.set(tokenExchangeDenial, denial), acceptsAuthorizationHeader: (authorization) => { const token = authorization?.replace(/^Bearer\s+/i, ""); return token 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..6b2f5de4fe 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 { EnterpriseIdentityProviderDescriptorSchema } from "@executor-js/sdk/core"; import { ApiKeyAuthMethod, ApiKeyAuthTemplate, @@ -62,9 +63,24 @@ 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. + * + * 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({ slug: Schema.String, kind: Schema.Literal("oauth2"), + enterpriseIdentityProvider: Schema.optional(McpEnterpriseIdentityProvider), }); export type McpOAuthMethod = typeof McpOAuthMethod.Type; @@ -128,7 +144,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