From b51e7bee3052c6168d05eb508af8156673a2f823 Mon Sep 17 00:00:00 2001 From: yalait Date: Mon, 14 Sep 2026 02:51:25 +0700 Subject: [PATCH 1/3] feat: verify end-user OIDC access tokens at the MCP endpoint --- docs/deployment.md | 37 ++ packaging/helm/artifact-server/README.md | 6 + project/spec/conformance.yml | 6 +- .../spec/decisions/0020-generic-oidc-login.md | 13 +- project/spec/decisions/0028-oidc-mcp-oauth.md | 127 +++++ src/application/authentication.ts | 2 + src/cli/lifecycle-commands.ts | 38 +- src/identity/oidc-hosted-authentication.ts | 62 +++ src/identity/oidc-mcp-bearer-verifier.ts | 315 ++++++++++++ src/identity/oidc-oauth-metadata.ts | 135 +++++ src/local/create-local-application-layer.ts | 2 +- .../oidc-mcp-authorization.test.ts | 473 ++++++++++++++++++ tests/integration/oidc-keycloak.test.ts | 188 ++++++- 13 files changed, 1382 insertions(+), 22 deletions(-) create mode 100644 project/spec/decisions/0028-oidc-mcp-oauth.md create mode 100644 src/identity/oidc-hosted-authentication.ts create mode 100644 src/identity/oidc-mcp-bearer-verifier.ts create mode 100644 src/identity/oidc-oauth-metadata.ts create mode 100644 tests/conformance/oidc-mcp-authorization.test.ts diff --git a/docs/deployment.md b/docs/deployment.md index b9b1563..b9063ed 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -39,6 +39,43 @@ Local-owner access works only on an exact loopback origin. Do not use it for rem Remote deployments use WorkOS or a generic OIDC provider. Network access and application authorization remain separate controls. +### Use a generic OIDC issuer for MCP + +The issuer configured for browser login also protects `/mcp`. Agents present an +end-user access token, and the server records the person who obtained it. The +server never issues client credentials and never runs an authorization server of +its own. + +The issuer must provide four things: + +- an OpenID Connect discovery document at `/.well-known/openid-configuration`; +- access tokens signed as JWTs with RS256 or ES256, verifiable against the + published JWKS; +- the authorization code flow with S256 PKCE; +- an access token whose `aud` contains the exact `/mcp`. + +The audience is the one step an operator must configure. Providers do not bind a +resource URL on their own. In Keycloak, add a client scope with an audience +mapper whose included custom audience is that exact URL, and assign the scope to +the client the agents use. Entra exposes an API and uses its application ID URI. +Okta sets the audience on a custom authorization server. A provider that +supports RFC 8707 resource indicators can bind it per request instead. + +Register the client the agents use in one of two supported ways: + +- the issuer offers RFC 7591 dynamic client registration, its discovery document + advertises `registration_endpoint`, and each client registers itself; +- an administrator registers one client in the issuer and gives its client ID to + the agents that need it. + +Artifact Server publishes RFC 9728 protected-resource metadata at +`/.well-known/oauth-protected-resource/mcp` naming the issuer, and answers an +unauthenticated MCP request with `401` and a `resource_metadata` challenge, so a +compliant client finds the issuer without further configuration. + +Clients that cannot complete OAuth keep using administration-issued API keys. +Tokens that name another resource, and ID tokens, are refused. + ## Back up the installation Back up metadata and artifact files as one coordinated recovery set. Use the procedure in the selected deployment guide. diff --git a/packaging/helm/artifact-server/README.md b/packaging/helm/artifact-server/README.md index b5380cd..db6b055 100644 --- a/packaging/helm/artifact-server/README.md +++ b/packaging/helm/artifact-server/README.md @@ -135,6 +135,12 @@ the chart mounts it as a file. `identity.oidcScopes` overrides the default `openid email profile`. The chart rejects a partial OIDC configuration, and an OIDC client secret or scope list without an issuer and client. +The same issuer also protects the MCP endpoint: agents may present an end-user +access token instead of an API key, and the server binds each call to the person +who obtained it. Such a token must name `configuration.applicationOrigin` +followed by `/mcp` in `aud`, which the provider produces from an audience mapper +or an RFC 8707 resource indicator. + One installation has one browser-login provider. The chart rejects values that configure neither provider or configure WorkOS and OIDC together. diff --git a/project/spec/conformance.yml b/project/spec/conformance.yml index b415ce1..09bbe3c 100644 --- a/project/spec/conformance.yml +++ b/project/spec/conformance.yml @@ -1964,15 +1964,15 @@ requirements: - id: MCP-013 kind: behavior - behavior: Local mode connects through artifactserver connect and a stdio bridge without browser OAuth or a human-visible secret; self-hosted mode uses browser OAuth when compatible authorization is configured and otherwise uses administration-issued scoped API keys. + behavior: Local mode connects through artifactserver connect and a stdio bridge without browser OAuth or a human-visible secret; self-hosted mode uses browser OAuth when compatible authorization is configured and otherwise uses administration-issued scoped API keys. A generic OIDC issuer is compatible authorization when it signs verifiable access-token JWTs, binds the exact /mcp audience, and offers a client registration path its clients can use; the installation registers that client and never runs an authorization server of its own. owner: mcp source: {file: artifact-server-product-spec.html, anchor: mcp} acceptance: - behavior: {id: MCP-013-B, description: "Connect locally through the stdio bridge with no visible credential, connect to a private OAuth server through browser approval, and connect to a no-OAuth server using an administration-issued revocable API key."} + behavior: {id: MCP-013-B, description: "Connect locally through the stdio bridge with no visible credential, connect to a private OAuth server through browser approval including a generic OIDC issuer whose access token names the exact /mcp resource, and connect to a no-OAuth server using an administration-issued revocable API key."} failure: {id: MCP-013-F, description: "Startup logs, diagnostics, project files, generic OIDC ID tokens, browser login cookies, and wrong-resource tokens cannot expose or substitute for a valid MCP credential."} deployments: [local, single_server, kubernetes, aws, gcp] status: implementing - proof_gap: Local stdio and API-key paths are implemented, but the combined private OAuth, no-OAuth, local, wrong-credential, and credential-leak matrix has no deployment-specific MCP-013 evidence. + proof_gap: Local stdio, API-key, and generic OIDC access-token paths are implemented and covered by the local conformance suite and the real Keycloak harness, but the combined private OAuth, no-OAuth, local, wrong-credential, and credential-leak matrix has no deployment-specific MCP-013 evidence. depends_on: [AUTH-009] evidence: [] diff --git a/project/spec/decisions/0020-generic-oidc-login.md b/project/spec/decisions/0020-generic-oidc-login.md index fc1ea99..d50e19d 100644 --- a/project/spec/decisions/0020-generic-oidc-login.md +++ b/project/spec/decisions/0020-generic-oidc-login.md @@ -99,11 +99,14 @@ the end-session endpoint. ## What stays excluded -- MCP OAuth. The MCP bearer path needs an authorization server that clients can - register against and whose tokens it can introspect. A bare enterprise - identity provider is generally not that server, so MCP authorization stays - WorkOS-only and OIDC installations use administration-issued API keys, which - already work everywhere. +- MCP OAuth, until [0028](./0028-oidc-mcp-oauth.md) reversed this. The exclusion + read that the MCP bearer path needs an authorization server clients can + register against and whose tokens it can introspect, and that a bare + enterprise identity provider is generally not that server. Introspection was + never part of the path: the WorkOS verifier reads a JWT against a discovered + JWKS, which a Keycloak, Entra, or Okta access token supports as well. + Registration is an operator step rather than a protocol gap. 0028 records what + an issuer must provide instead. - Directory sync, SCIM, role and group mapping, and provisioning beyond the existing bootstrap-administrator rule. Admission stays explicit. - Refresh tokens and `offline_access`. Sessions are server-side records with diff --git a/project/spec/decisions/0028-oidc-mcp-oauth.md b/project/spec/decisions/0028-oidc-mcp-oauth.md new file mode 100644 index 0000000..4e08900 --- /dev/null +++ b/project/spec/decisions/0028-oidc-mcp-oauth.md @@ -0,0 +1,127 @@ +# 0028: MCP OAuth through the configured OIDC issuer + +**Status:** Accepted +**Date:** September 14, 2026 + +Supersedes the "MCP OAuth" exclusion recorded in +[0020: Generic OIDC browser login](0020-generic-oidc-login.md). + +## Decision + +An installation that configures a generic OIDC issuer for browser login now +also accepts end-user access tokens from that issuer at `/mcp`. A second +implementation of the existing `ExternalMcpBearerVerifier` port, +`OidcMcpBearerVerifier`, sits beside the WorkOS one. Managed API keys keep +working and are still checked first, so nothing an installation already uses +changes. + +0020 excluded this on the reading that a bare enterprise identity provider is +not an authorization server MCP clients can register against. Keycloak, Entra, +Okta, and Auth0 all publish `/.well-known/openid-configuration`, most of them +offer dynamic client registration, and an MCP client that reads RFC 9728 +protected-resource metadata reaches the issuer on its own. What the exclusion +actually costs is real: without it every agent on a self-hosted installation +shares one API key, and the server records one author for everyone behind it. + +## What it does + +Startup fetches the issuer's OIDC discovery document, validates it, and keeps +three things: the JWKS URI, the userinfo endpoint, and the document itself. The +document is served back at `/.well-known/oauth-authorization-server`, and +`/.well-known/oauth-protected-resource/mcp` names the issuer as the +authorization server for the `/mcp` resource. An unauthenticated MCP +request answers 401 with `resource_metadata`, which is the whole handshake an +MCP client needs. + +A presented token is verified against the discovered JWKS: signature, RS256 or +ES256, exact issuer, audience, expiry, and a non-empty subject, with the same +thirty-second clock tolerance browser login uses. The member binding is +`oidc:` paired with `sub`, the same binding browser login +writes, so one person keeps one membership whichever way they arrive. + +Identity on first use comes from the token's own `email`, `name`, +`given_name`, `family_name`, and `preferred_username` claims. An installation +whose access tokens carry no email falls back to the discovered userinfo +endpoint, called once with the presented token. Both paths end at the existing +admission gate, which still decides who may enter. + +## Recorded decisions + +### The audience is the MCP URL, and nothing else + +`aud` must contain `/mcp`, per the MCP specification, +and there is no setting that accepts a different value. Membership in a +multi-valued `aud` is enough: Keycloak names `account` beside the requested +audience, and refusing that would refuse Keycloak. Binding the resource URL is +the operator's step, through an audience mapper or an RFC 8707 resource +indicator, and the deployment guide says so. + +### An ID token is not an MCP credential + +A payload `typ` of `ID` is refused. Keycloak marks its ID tokens that way, and +MCP-013-F requires that an ID token cannot substitute for an MCP credential. + +### An issuer that cannot serve its keys is unavailable, not a bad token + +A key-set response that is not 200 arrives from `jose` as a generic error, which +would otherwise read as an invalid token and answer 401. The key-set fetch +therefore raises its own failure, and the endpoint answers with a provider +failure instead of blaming the credential. + +### Discovery runs at startup, and a down issuer only turns MCP OAuth off + +The protected-resource document cannot be served without the discovery +document, so it is read once at startup, like the WorkOS path reads its +authorization-server metadata. Unlike that path, a discovery failure is not +fatal: the process writes one warning to stderr and starts with browser login +and managed API keys, which is exactly the behavior an installation had before +this change. An identity provider that is briefly down must not take the +artifact server down with it, and browser login keeps its own lazy discovery +anyway. + +### The credential travels to identity resolution + +`resolveIdentity` now receives the credential beside the verified claims. The +WorkOS implementation ignores it and reads its own API. The OIDC implementation +needs it, because an issuer's userinfo endpoint answers the presenter of the +token, not a server credential. + +### Client registration belongs to the issuer + +The installation registers one client, or the issuer offers RFC 7591 dynamic +registration and clients register themselves. Artifact Server advertises +whatever `registration_endpoint` the issuer publishes and issues no client +credentials of its own, which keeps MCP-014 intact: no embedded authorization +server appears here. + +## What stays excluded + +- OAuth on the HTTP API. `apiOAuthResource` and `externalApiBearerVerifier` + stay unset, so `/api/` keeps accepting managed API keys only. Advertising an + authorization server for a resource that cannot accept its tokens would be a + false promise. +- Scope checks. The resource-bound audience grants MCP access, matching the + rule MCP-011 already records for WorkOS. +- Dynamic client registration by Artifact Server. The issuer owns registration; + Artifact Server only points clients at it. + +## Rejected alternatives + +### Keep MCP on API keys for OIDC installations + +This is the status quo 0020 recorded. It gives every agent the same identity, +makes revocation all-or-nothing, and puts a long-lived shared secret into every +client configuration, including gateways that forward other people's requests. + +### Cache the profile claims from `verify` for `resolveIdentity` to read + +This avoids the port change by keeping token claims in a map between two calls +of the same request. It adds a cache with an eviction policy, a race, and a +failure mode that only appears under load, to avoid passing a value that the +caller already holds. + +### Accept any audience and rely on the issuer check + +An access token minted for another service in the same realm would then open +this one. Audience binding is the property that makes a resource server safe to +point several clients at. diff --git a/src/application/authentication.ts b/src/application/authentication.ts index fb17de5..c0164ab 100644 --- a/src/application/authentication.ts +++ b/src/application/authentication.ts @@ -38,8 +38,10 @@ export interface VerifiedExternalMcpBearer { /** External MCP token verification and first-use identity resolution. */ export interface ExternalMcpBearerVerifier { + /** The credential travels along so a provider can read the profile it carries. */ readonly resolveIdentity: ( verified: VerifiedExternalMcpBearer, + credential: Redacted.Redacted, ) => Effect.Effect< ExternalIdentity, AuthenticationRequired | IdentityProviderFailure diff --git a/src/cli/lifecycle-commands.ts b/src/cli/lifecycle-commands.ts index fc91627..8f6e8cd 100644 --- a/src/cli/lifecycle-commands.ts +++ b/src/cli/lifecycle-commands.ts @@ -11,6 +11,10 @@ import { startExternalStorageServer, type ExternalStorageServerConfig, } from "../external-storage/start-external-storage-server.js"; +import { + createOidcHostedAuthentication, + type OidcHostedAuthentication, +} from "../identity/oidc-hosted-authentication.js"; import {createOidcIdentityProvider} from "../identity/oidc-identity-provider.js"; import {createWorkOsHostedAuthentication} from @@ -49,6 +53,7 @@ import {waitForProcessSignal} from "./wait-for-process-signal.js"; import { assertAtMostOneBrowserLoginProvider, loadOidcConfiguration, + type OidcConfiguration, } from "./oidc-configuration.js"; import {loadWorkOsConfiguration} from "./workos-configuration.js"; import {writeGitHistoryConfigurationWarnings} from @@ -308,6 +313,9 @@ function configureExternalStorageStart( const hostedAuthentication = workOs === null ? null : await createWorkOsHostedAuthentication(workOs); + const oidcAuthentication = oidc === null + ? null + : await oidcAuthenticationOrBrowserOnly(oidc); const browserAccess = hostedAuthentication !== null ? privateTeamBrowserAccess(browserLoginKinds.workOs) : oidc !== null @@ -338,10 +346,10 @@ function configureExternalStorageStart( ...hostedAuthentication, }; } - if (oidc !== null) { + if (oidcAuthentication !== null) { serverConfig = { ...serverConfig, - interactiveIdentityProvider: createOidcIdentityProvider(oidc), + ...oidcAuthentication, }; } const server = await startExternalStorageServer(serverConfig); @@ -376,6 +384,9 @@ async function startCompactServer( const hostedAuthentication = workOs === null ? null : await createWorkOsHostedAuthentication(workOs); + const oidcAuthentication = oidc === null + ? null + : await oidcAuthenticationOrBrowserOnly(oidc); const browserAccess = hostedAuthentication !== null ? privateTeamBrowserAccess(browserLoginKinds.workOs) : oidc !== null @@ -407,15 +418,34 @@ async function startCompactServer( ...hostedAuthentication, }; } - if (oidc !== null) { + if (oidcAuthentication !== null) { serverConfig = { ...serverConfig, - interactiveIdentityProvider: createOidcIdentityProvider(oidc), + ...oidcAuthentication, }; } return startLocalServer(serverConfig); } +type OidcServerAuthentication = + | OidcHostedAuthentication + | Pick; + +/** MCP OAuth needs the issuer at startup; browser login must survive it being down. */ +async function oidcAuthenticationOrBrowserOnly( + oidc: OidcConfiguration, +): Promise { + try { + return await createOidcHostedAuthentication(oidc); + } catch (cause) { + const reason = cause instanceof Error ? cause.message : "the request failed"; + process.stderr.write( + `OIDC configuration warning (discovery_failed): MCP OAuth stays off for ${oidc.issuer}: ${reason}\n`, + ); + return {interactiveIdentityProvider: createOidcIdentityProvider(oidc)}; + } +} + async function lifecycleConfiguration( options: LifecycleOptions, ): Promise { diff --git a/src/identity/oidc-hosted-authentication.ts b/src/identity/oidc-hosted-authentication.ts new file mode 100644 index 0000000..cd2d874 --- /dev/null +++ b/src/identity/oidc-hosted-authentication.ts @@ -0,0 +1,62 @@ +import type {Redacted} from "effect"; + +import type {ExternalMcpBearerVerifier} from "../application/authentication.js"; +import type {InteractiveIdentityProvider} from "../application/interactive-login.js"; +import type {McpOAuthResourceConfiguration} from "../http/create-http-app.js"; +import {createOidcIdentityProvider} from "./oidc-identity-provider.js"; +import {requireOidcIssuer} from "./oidc-issuer.js"; +import { + OidcMcpBearerVerifier, + type OidcMcpBearerVerifierConfig, +} from "./oidc-mcp-bearer-verifier.js"; +import {loadOidcAuthorizationServer} from "./oidc-oauth-metadata.js"; + +export interface OidcHostedAuthenticationConfig { + readonly applicationOrigin: string; + readonly clientId: string; + readonly clientSecret: Redacted.Redacted | null; + readonly fetch?: typeof globalThis.fetch; + readonly issuer: string; + readonly scopes: string; +} + +export interface OidcHostedAuthentication { + readonly externalMcpOAuthVerifier: ExternalMcpBearerVerifier; + readonly interactiveIdentityProvider: InteractiveIdentityProvider; + readonly mcpOAuthResource: McpOAuthResourceConfiguration; +} + +/** Build browser and MCP authentication from one generic OIDC issuer. */ +export async function createOidcHostedAuthentication( + config: OidcHostedAuthenticationConfig, +): Promise { + const issuer = requireOidcIssuer(config.issuer, "ARTIFACT_SERVER_OIDC_ISSUER"); + const resource = new URL("/mcp", config.applicationOrigin).toString(); + const authorizationServer = await loadOidcAuthorizationServer( + issuer, + config.fetch === undefined ? {} : {fetch: config.fetch}, + ); + let verifierConfig: OidcMcpBearerVerifierConfig = { + audience: resource, + issuer, + jwksUri: authorizationServer.jwksUri, + userInfoEndpoint: authorizationServer.userInfoEndpoint, + }; + if (config.fetch !== undefined) { + verifierConfig = {...verifierConfig, fetch: config.fetch}; + } + return { + externalMcpOAuthVerifier: new OidcMcpBearerVerifier(verifierConfig), + interactiveIdentityProvider: createOidcIdentityProvider({ + applicationOrigin: config.applicationOrigin, + clientId: config.clientId, + clientSecret: config.clientSecret, + issuer, + scopes: config.scopes, + }), + mcpOAuthResource: { + authorizationServerMetadata: authorizationServer.metadata, + resource, + }, + }; +} diff --git a/src/identity/oidc-mcp-bearer-verifier.ts b/src/identity/oidc-mcp-bearer-verifier.ts new file mode 100644 index 0000000..516272d --- /dev/null +++ b/src/identity/oidc-mcp-bearer-verifier.ts @@ -0,0 +1,315 @@ +import { + createRemoteJWKSet, + customFetch, + errors as joseErrors, + jwtVerify, + type JWTPayload, +} from "jose"; +import {Effect, Predicate, Redacted, Schema} from "effect"; + +import type { + ExternalMcpBearerVerifier, + VerifiedExternalMcpBearer, +} from "../application/authentication.js"; +import { + AuthenticationRequired, + IdentityProviderFailure, +} from "../core/errors.js"; +import type {ExternalIdentity} from "../core/installation-identity.js"; +import {requireOidcIssuer} from "./oidc-issuer.js"; + +const defaultAlgorithms = ["RS256", "ES256"]; +const idTokenType = "ID"; +const clockTolerance = "30s"; +const jwksCacheMilliseconds = 10 * 60 * 1_000; +const jwksCooldownMilliseconds = 5 * 60 * 1_000; +const jwksTimeoutMilliseconds = 5_000; +const requestTimeoutMilliseconds = 5_000; + +const profileClaims = { + email: Schema.optionalKey(Schema.String), + email_verified: Schema.optionalKey(Schema.Boolean), + family_name: Schema.optionalKey(Schema.String), + given_name: Schema.optionalKey(Schema.String), + name: Schema.optionalKey(Schema.String), + preferred_username: Schema.optionalKey(Schema.String), +}; +const accessTokenClaims = Schema.Struct({ + ...profileClaims, + azp: Schema.optionalKey(Schema.String), + client_id: Schema.optionalKey(Schema.String), + exp: Schema.Number, + scope: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Array(Schema.String)]), + ), + sub: Schema.NonEmptyString, + typ: Schema.optionalKey(Schema.String), +}); +const userInfoClaims = Schema.Struct({ + ...profileClaims, + sub: Schema.NonEmptyString, +}); +const decodeAccessTokenClaims = Schema.decodeUnknownEffect(accessTokenClaims); +const decodeUserInfoClaims = Schema.decodeUnknownEffect(userInfoClaims); + +type AccessTokenClaims = typeof accessTokenClaims.Type; +type ProfileClaims = typeof userInfoClaims.Type; + +export interface OidcMcpBearerVerifierConfig { + readonly algorithms?: readonly string[]; + /** The exact MCP resource URL the issuer must bind into `aud`. */ + readonly audience: string; + readonly fetch?: typeof globalThis.fetch; + readonly issuer: string; + readonly jwksUri: string; + readonly userInfoEndpoint?: string | null; +} + +/** Verify end-user OIDC access tokens presented to the MCP endpoint. */ +export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { + readonly #algorithms: string[]; + readonly #audience: string; + readonly #fetch: typeof globalThis.fetch; + readonly #issuer: string; + readonly #jwks: ReturnType; + readonly #provider: string; + readonly #userInfoEndpoint: string | null; + + constructor(config: OidcMcpBearerVerifierConfig) { + this.#algorithms = [...(config.algorithms ?? defaultAlgorithms)]; + this.#audience = config.audience; + const providerFetch = config.fetch ?? globalThis.fetch; + this.#fetch = (input, init) => providerFetch(input, init); + this.#issuer = requireOidcIssuer(config.issuer, "The OIDC issuer"); + // Browser login records the same provider name, so one person keeps one + // membership whether they arrive through the interface or through MCP. + this.#provider = `oidc:${this.#issuer}`; + this.#userInfoEndpoint = config.userInfoEndpoint ?? null; + this.#jwks = createRemoteJWKSet(new URL(config.jwksUri), { + cacheMaxAge: jwksCacheMilliseconds, + cooldownDuration: jwksCooldownMilliseconds, + // An issuer that answers the key set with an error is unavailable, not a + // reason to call the presented token invalid. + [customFetch]: async (input, init) => { + const response = await providerFetch(input, init); + if (response.status !== 200) throw new KeySetUnavailable(); + return response; + }, + timeoutDuration: jwksTimeoutMilliseconds, + }); + } + + readonly verify = Effect.fn("OidcMcpBearerVerifier.verify")( + function*(this: OidcMcpBearerVerifier, credential: Redacted.Redacted) { + const claims = yield* this.#verifiedClaims(credential); + const verified: VerifiedExternalMcpBearer = { + clientId: claims.client_id ?? claims.azp ?? null, + expiresAt: claims.exp, + provider: this.#provider, + scopes: tokenScopes(claims.scope), + subject: claims.sub, + }; + return verified; + }, + ); + + readonly resolveIdentity = Effect.fn("OidcMcpBearerVerifier.resolveIdentity")( + function*( + this: OidcMcpBearerVerifier, + verified: VerifiedExternalMcpBearer, + credential: Redacted.Redacted, + ) { + if (verified.provider !== this.#provider) { + return yield* invalidToken("The access token issuer is not supported."); + } + const claims = yield* this.#verifiedClaims(credential); + if (claims.sub !== verified.subject) { + return yield* invalidToken( + "The access token names a different subject than the verified one.", + ); + } + const email = emailOf(claims); + if (email !== null) return this.#identityOf(claims, email); + return yield* this.#userInfoIdentity(credential, claims.sub); + }, + ); + + /** Verify signature, issuer, audience, and expiry, then read the claims. */ + #verifiedClaims( + credential: Redacted.Redacted, + ): Effect.Effect< + AccessTokenClaims, + AuthenticationRequired | IdentityProviderFailure + > { + return Effect.tryPromise({ + try: async () => { + const result = await jwtVerify(Redacted.value(credential), this.#jwks, { + algorithms: this.#algorithms, + audience: this.#audience, + clockTolerance, + issuer: this.#issuer, + }); + return result.payload; + }, + catch: (cause) => verificationFailure(cause), + }).pipe(Effect.flatMap(decodeClaims)); + } + + readonly #userInfoIdentity = Effect.fn("OidcMcpBearerVerifier.userInfo")( + function*( + this: OidcMcpBearerVerifier, + credential: Redacted.Redacted, + subject: string, + ) { + const endpoint = this.#userInfoEndpoint; + if (endpoint === null) { + return yield* invalidToken( + "The OIDC access token carries no email claim and the issuer offers no userinfo endpoint.", + ); + } + const response = yield* Effect.tryPromise({ + try: (signal) => this.#fetch(endpoint, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${Redacted.value(credential)}`, + }, + redirect: "manual", + signal: requestSignal(signal), + }), + catch: () => providerUnavailable( + "The OIDC userinfo endpoint could not be reached.", + ), + }); + if (response.status === 401 || response.status === 403) { + return yield* invalidToken( + "The issuer rejected this access token at its userinfo endpoint.", + ); + } + if (!response.ok) { + return yield* providerUnavailable( + "The OIDC userinfo endpoint returned an unexpected response.", + ); + } + const body = yield* Effect.tryPromise({ + try: () => response.json(), + catch: () => providerUnavailable( + "The OIDC userinfo endpoint returned invalid JSON.", + ), + }); + const profile = yield* decodeUserInfoClaims(body).pipe( + Effect.mapError(() => providerUnavailable( + "The OIDC userinfo endpoint returned an invalid profile.", + )), + ); + if (profile.sub !== subject) { + return yield* providerUnavailable( + "The OIDC userinfo endpoint returned a different subject.", + ); + } + const email = emailOf(profile); + if (email === null) { + return yield* invalidToken( + "The OIDC profile for this access token carries no email address.", + ); + } + return this.#identityOf(profile, email); + }, + ); + + #identityOf(claims: ProfileClaims, email: string): ExternalIdentity { + return { + displayName: displayName(claims, email), + email, + emailVerified: claims.email_verified !== false, + provider: this.#provider, + subject: claims.sub, + }; + } +} + +function decodeClaims( + payload: JWTPayload, +): Effect.Effect { + return decodeAccessTokenClaims(payload).pipe( + Effect.mapError(() => invalidToken( + "The OIDC access token is missing required claims.", + )), + Effect.flatMap((claims) => + // Keycloak marks ID tokens with typ ID, and an ID token is not a + // credential for this endpoint even when it names the same audience. + claims.typ === idTokenType + ? Effect.fail(invalidToken( + "An ID token is not an Artifact Server MCP credential.", + )) + : Effect.succeed(claims) + ), + ); +} + +function emailOf(claims: ProfileClaims): string | null { + const email = claims.email?.trim() ?? ""; + return email === "" ? null : email; +} + +function displayName(claims: ProfileClaims, email: string): string { + const name = claims.name?.trim() ?? ""; + if (name !== "") return name; + const parts = [claims.given_name, claims.family_name] + .map((part) => part?.trim() ?? "") + .filter((part) => part !== ""); + if (parts.length > 0) return parts.join(" "); + const username = claims.preferred_username?.trim() ?? ""; + return username === "" ? email : username; +} + +function tokenScopes( + value: string | readonly string[] | undefined, +): readonly string[] { + if (value === undefined) return []; + if (Predicate.isString(value)) { + return [...new Set(value.split(/\s+/u).filter((scope) => scope !== ""))]; + } + return [...new Set(value)]; +} + +/** The issuer answered the key set with something other than a key set. */ +class KeySetUnavailable extends Error {} + +function verificationFailure( + cause: unknown, +): AuthenticationRequired | IdentityProviderFailure { + if ( + cause instanceof KeySetUnavailable || + cause instanceof TypeError || + cause instanceof joseErrors.JWKSTimeout || + ( + Predicate.isObject(cause) && "code" in cause && + cause["code"] === "ERR_JWKS_FETCH_FAILED" + ) + ) { + return providerUnavailable( + cause instanceof joseErrors.JWKSTimeout + ? "The OIDC signing-key lookup timed out." + : "The OIDC signing keys could not be loaded.", + ); + } + return new AuthenticationRequired({ + message: "The OIDC access token is invalid, expired, or for another resource.", + }); +} + +function invalidToken(message: string): AuthenticationRequired { + return new AuthenticationRequired({message}); +} + +function providerUnavailable(message: string): IdentityProviderFailure { + return new IdentityProviderFailure({message}); +} + +/** Bound every userinfo request so one stalled issuer cannot pin a request. */ +function requestSignal(interrupt: AbortSignal): AbortSignal { + return AbortSignal.any([ + interrupt, + AbortSignal.timeout(requestTimeoutMilliseconds), + ]); +} diff --git a/src/identity/oidc-oauth-metadata.ts b/src/identity/oidc-oauth-metadata.ts new file mode 100644 index 0000000..ef8e0c1 --- /dev/null +++ b/src/identity/oidc-oauth-metadata.ts @@ -0,0 +1,135 @@ +import type {OAuthMetadata} from "@modelcontextprotocol/server"; +import {z} from "zod"; + +import { + isLocalOidcIssuer, + normalizeOidcEndpoint, + normalizeOidcIssuer, + requireOidcIssuer, +} from "./oidc-issuer.js"; + +const openIdConfigurationPath = "/.well-known/openid-configuration"; +const defaultTimeoutMilliseconds = 5_000; + +const discoveryDocumentSchema = z.looseObject({ + authorization_endpoint: z.string().min(1), + code_challenge_methods_supported: z.array(z.string()).optional(), + issuer: z.string().min(1), + jwks_uri: z.string().min(1), + registration_endpoint: z.string().min(1).optional(), + response_types_supported: z.array(z.string()), + revocation_endpoint: z.string().min(1).optional(), + token_endpoint: z.string().min(1), + userinfo_endpoint: z.string().min(1).optional(), +}); + +/** Authorization-server contract one OIDC issuer offers to MCP clients. */ +export interface OidcAuthorizationServer { + readonly jwksUri: string; + readonly metadata: OAuthMetadata; + readonly userInfoEndpoint: string | null; +} + +export interface OidcOAuthMetadataOptions { + readonly fetch?: typeof globalThis.fetch; + readonly timeoutMilliseconds?: number; +} + +/** Fetch and validate the OIDC authorization-server contract at startup. */ +export async function loadOidcAuthorizationServer( + issuer: string, + options: OidcOAuthMetadataOptions = {}, +): Promise { + const exactIssuer = requireOidcIssuer(issuer, "The OIDC issuer"); + const response = await (options.fetch ?? globalThis.fetch)( + `${exactIssuer}${openIdConfigurationPath}`, + { + headers: {Accept: "application/json"}, + // Never follow discovery off the validated issuer origin. + redirect: "manual", + signal: AbortSignal.timeout( + options.timeoutMilliseconds ?? defaultTimeoutMilliseconds, + ), + }, + ); + if (!response.ok) { + throw new Error(`OIDC discovery returned HTTP ${response.status}.`); + } + const document = discoveryDocumentSchema.parse(await response.json()); + if (normalizeOidcIssuer(document.issuer) !== exactIssuer) { + throw new Error("OIDC discovery returned a different issuer."); + } + const allowLocalHttp = isLocalOidcIssuer(exactIssuer); + const authorizationEndpoint = requireEndpoint( + document.authorization_endpoint, + "authorization endpoint", + allowLocalHttp, + ); + const tokenEndpoint = requireEndpoint( + document.token_endpoint, + "token endpoint", + allowLocalHttp, + ); + const jwksUri = requireEndpoint(document.jwks_uri, "JWKS URI", allowLocalHttp); + const userInfoEndpoint = optionalEndpoint( + document.userinfo_endpoint, + "userinfo endpoint", + allowLocalHttp, + ); + // These two are served on to MCP clients, so a client must not be sent + // anywhere this server would have refused to go itself. + const registrationEndpoint = optionalEndpoint( + document.registration_endpoint, + "registration endpoint", + allowLocalHttp, + ); + const revocationEndpoint = optionalEndpoint( + document.revocation_endpoint, + "revocation endpoint", + allowLocalHttp, + ); + if (!document.response_types_supported.includes("code")) { + throw new Error( + "OIDC discovery does not support authorization code login.", + ); + } + if (!document.code_challenge_methods_supported?.includes("S256")) { + throw new Error("OIDC discovery does not advertise S256 PKCE."); + } + let metadata: OAuthMetadata = { + ...document, + authorization_endpoint: authorizationEndpoint, + issuer: exactIssuer, + jwks_uri: jwksUri, + token_endpoint: tokenEndpoint, + }; + if (registrationEndpoint !== null) { + metadata = {...metadata, registration_endpoint: registrationEndpoint}; + } + if (revocationEndpoint !== null) { + metadata = {...metadata, revocation_endpoint: revocationEndpoint}; + } + return {jwksUri, metadata, userInfoEndpoint}; +} + +function optionalEndpoint( + value: string | undefined, + name: string, + allowLocalHttp: boolean, +): string | null { + return value === undefined + ? null + : requireEndpoint(value, name, allowLocalHttp); +} + +function requireEndpoint( + value: string, + name: string, + allowLocalHttp: boolean, +): string { + const endpoint = normalizeOidcEndpoint(value, allowLocalHttp); + if (endpoint === null) { + throw new Error(`The OIDC ${name} must be an HTTPS URL.`); + } + return endpoint; +} diff --git a/src/local/create-local-application-layer.ts b/src/local/create-local-application-layer.ts index 2f6069a..3e39075 100644 --- a/src/local/create-local-application-layer.ts +++ b/src/local/create-local-application-layer.ts @@ -947,7 +947,7 @@ export function createApplicationLayer( verified.subject, ); if (principal === null) { - const identity = yield* verifier.resolveIdentity(verified); + const identity = yield* verifier.resolveIdentity(verified, credential); principal = yield* installationAccess.authenticateExternalIdentity(identity); } return { diff --git a/tests/conformance/oidc-mcp-authorization.test.ts b/tests/conformance/oidc-mcp-authorization.test.ts new file mode 100644 index 0000000..948f8cd --- /dev/null +++ b/tests/conformance/oidc-mcp-authorization.test.ts @@ -0,0 +1,473 @@ +import {createServer, type Server} from "node:http"; + +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, +} from "@modelcontextprotocol/server"; +import { + exportJWK, + generateKeyPair, + SignJWT, + type CryptoKey, + type JWK, +} from "jose"; +import {Effect, Predicate, Redacted} from "effect"; +import {afterEach, beforeEach, describe, expect, test} from "vitest"; + +import {createOidcHostedAuthentication} from + "../../src/identity/oidc-hosted-authentication.js"; +import {OidcMcpBearerVerifier} from + "../../src/identity/oidc-mcp-bearer-verifier.js"; +import {loadOidcAuthorizationServer} from + "../../src/identity/oidc-oauth-metadata.js"; +import type {ExternalMcpBearerVerifier} from + "../../src/application/authentication.js"; +import { + createTestInstallation, + removeTestInstallation, + type RunningTestServer, + startTestServer, + type TestInstallation, +} from "../support/runtime-harness.js"; + +const protocolVersion = "2026-07-28"; +const realmPath = "/realms/artifact"; +const resource = "https://staging.artifactserver.test/mcp"; +const subject = "9f1d2c3b-0000-4000-8000-artifactserver"; +const userEmail = "administrator@example.test"; + +describe("generic OIDC MCP authorization", () => { + let installation: TestInstallation; + let issuer: string; + let keyId: string; + let privateKey: CryptoKey; + let provider: ProviderBoundary; + let server: RunningTestServer; + + beforeEach(async () => { + const keys = await generateKeyPair("RS256"); + privateKey = keys.privateKey; + keyId = crypto.randomUUID(); + const publicJwk = await exportJWK(keys.publicKey); + provider = await startProviderBoundary({ + jwk: {...publicJwk, alg: "RS256", kid: keyId, use: "sig"}, + }); + issuer = provider.issuer; + const authorizationServer = await loadOidcAuthorizationServer(issuer); + installation = await createTestInstallation(); + server = await startTestServer(installation, { + bootstrapAdministratorEmail: userEmail, + externalMcpOAuthVerifier: new OidcMcpBearerVerifier({ + audience: resource, + issuer, + jwksUri: authorizationServer.jwksUri, + userInfoEndpoint: authorizationServer.userInfoEndpoint, + }), + mcpOAuthResource: { + authorizationServerMetadata: authorizationServer.metadata, + resource, + }, + }); + }); + + afterEach(async () => { + await server.stop(); + await provider.close(); + await removeTestInstallation(installation); + }); + + test("discovery accepts one realm-path issuer and refuses a weaker contract", async () => { + expect.hasAssertions(); + const authorizationServer = await loadOidcAuthorizationServer(issuer); + expect(authorizationServer.metadata).toMatchObject({issuer}); + expect(authorizationServer.jwksUri).toBe( + `${issuer}/protocol/openid-connect/certs`, + ); + expect(authorizationServer.userInfoEndpoint).toBe( + `${issuer}/protocol/openid-connect/userinfo`, + ); + + await expect(loadOidcAuthorizationServer(issuer, { + fetch: async () => documentResponse({ + ...discoveryDocument(issuer), + issuer: "https://attacker.example", + }), + })).rejects.toThrow("different issuer"); + await expect(loadOidcAuthorizationServer(issuer, { + fetch: async () => documentResponse({ + ...discoveryDocument(issuer), + code_challenge_methods_supported: ["plain"], + }), + })).rejects.toThrow("S256 PKCE"); + await expect(loadOidcAuthorizationServer(issuer, { + fetch: async () => documentResponse({ + ...discoveryDocument(issuer), + response_types_supported: ["token"], + }), + })).rejects.toThrow("authorization code login"); + await expect(loadOidcAuthorizationServer(issuer, { + fetch: async () => documentResponse({ + ...discoveryDocument(issuer), + registration_endpoint: "http://attacker.example/register", + }), + })).rejects.toThrow("registration endpoint"); + }); + + test("an end-user token authenticates once and keeps its own membership", async () => { + expect.hasAssertions(); + const protectedMetadata = await fetch( + `${server.baseUrl}/.well-known/oauth-protected-resource/mcp`, + ); + expect(protectedMetadata.status).toBe(200); + expect(await protectedMetadata.json()).toMatchObject({ + authorization_servers: [issuer], + bearer_methods_supported: ["header"], + resource, + }); + + const authorizationMetadata = await fetch( + `${server.baseUrl}/.well-known/oauth-authorization-server`, + ); + expect(authorizationMetadata.status).toBe(200); + expect(await authorizationMetadata.json()).toMatchObject({issuer}); + + const missing = await mcpDiscovery(null); + expect(missing.status).toBe(401); + expect(missing.headers.get("www-authenticate")).toContain( + `resource_metadata="${resource.replace( + "/mcp", + "/.well-known/oauth-protected-resource/mcp", + )}"`, + ); + + const token = await issueToken({}); + expect((await mcpDiscovery(token)).status).toBe(200); + // The token carries the profile, so the issuer is never asked for it. + expect(provider.userInfoRequests()).toBe(0); + expect((await mcpDiscovery(token)).status).toBe(200); + + expect((await mcpDiscovery(installation.apiToken)).status).toBe(200); + }); + + test("a lean token resolves its profile through userinfo exactly once", async () => { + expect.hasAssertions(); + const token = await issueToken({profile: false}); + expect((await mcpDiscovery(token)).status).toBe(200); + expect(provider.userInfoRequests()).toBe(1); + + expect((await mcpDiscovery(token)).status).toBe(200); + expect(provider.userInfoRequests()).toBe(1); + }); + + test("only this issuer, this algorithm, and these keys are accepted", async () => { + expect.hasAssertions(); + const otherIssuer = await startProviderBoundary({ + jwk: await exportedJwk(keyId), + }); + try { + // A token another issuer signed with the same key material is still a + // token for another issuer. + expect((await mcpDiscovery(await issueToken({ + issuer: otherIssuer.issuer, + }))).status).toBe(401); + } finally { + await otherIssuer.close(); + } + + const symmetric = await new SignJWT({azp: "artifact-server"}) + .setProtectedHeader({alg: "HS256", kid: keyId}) + .setIssuer(issuer) + .setAudience(resource) + .setIssuedAt() + .setExpirationTime(Math.floor(Date.now() / 1_000) + 300) + .setSubject(subject) + .sign(new TextEncoder().encode("a shared secret is not a signing key")); + expect((await mcpDiscovery(symmetric)).status).toBe(401); + + const unknownKey = await generateKeyPair("RS256"); + const unknownKid = await new SignJWT({azp: "artifact-server"}) + .setProtectedHeader({alg: "RS256", kid: crypto.randomUUID()}) + .setIssuer(issuer) + .setAudience(resource) + .setIssuedAt() + .setExpirationTime(Math.floor(Date.now() / 1_000) + 300) + .setSubject(subject) + .sign(unknownKey.privateKey); + expect((await mcpDiscovery(unknownKid)).status).toBe(401); + + // A fresh verifier has no cached keys, so the outage is the first thing it + // meets and must not read as a bad token. + const coldVerifier = new OidcMcpBearerVerifier({ + audience: resource, + issuer, + jwksUri: `${issuer}/protocol/openid-connect/certs`, + }); + provider.setKeysStatus(503); + try { + await expect(verifiedSubject(coldVerifier, await issueToken({}))) + .rejects.toThrow("signing keys"); + } finally { + provider.setKeysStatus(200); + } + }); + + test("wrong token contracts fail closed and issuer outages stay distinct", async () => { + expect.hasAssertions(); + expect((await mcpDiscovery(await issueToken({ + audience: "https://attacker.example/mcp", + }))).status).toBe(401); + + expect((await mcpDiscovery(await issueToken({ + issuer: "https://attacker.example", + }))).status).toBe(401); + + expect((await mcpDiscovery(await issueToken({ + expiresAt: Math.floor(Date.now() / 1_000) - 60, + }))).status).toBe(401); + + expect((await mcpDiscovery(await issueToken({subject: null}))).status) + .toBe(401); + + const untrusted = await generateKeyPair("RS256"); + expect((await mcpDiscovery(await issueToken({ + signingKey: untrusted.privateKey, + }))).status).toBe(401); + + expect((await mcpDiscovery("not-a-jwt")).status).toBe(401); + + expect((await mcpDiscovery(await issueToken({idToken: true}))).status) + .toBe(401); + + provider.setUserInfoStatus(401); + expect((await mcpDiscovery(await issueToken({profile: false}))).status) + .toBe(401); + provider.setUserInfoStatus(503); + expect((await mcpDiscovery(await issueToken({profile: false}))).status) + .toBe(500); + }); + + test("a token that names several audiences is accepted", async () => { + expect.hasAssertions(); + // Keycloak names account beside the requested audience, so membership of + // the MCP resource is what grants access. + expect((await mcpDiscovery(await issueToken({ + audience: [resource, "account"], + }))).status).toBe(200); + }); + + test("the MCP resource follows the application origin and nothing else opens it", async () => { + expect.hasAssertions(); + const hosted = await createOidcHostedAuthentication({ + applicationOrigin: "https://staging.artifactserver.test", + clientId: "artifact-server", + clientSecret: null, + issuer, + scopes: "openid email profile", + }); + expect(hosted.mcpOAuthResource.resource).toBe(resource); + expect(hosted.mcpOAuthResource.authorizationServerMetadata) + .toMatchObject({issuer}); + await expect(verifiedSubject(hosted.externalMcpOAuthVerifier, await + issueToken({}))).resolves.toBe(subject); + await expect(verifiedSubject(hosted.externalMcpOAuthVerifier, await + issueToken({audience: "artifact-server"}))) + .rejects.toThrow("for another resource"); + await expect(verifiedSubject(hosted.externalMcpOAuthVerifier, await + issueToken({audience: ["account", "https://attacker.example/mcp"]}))) + .rejects.toThrow("for another resource"); + await expect(verifiedSubject(hosted.externalMcpOAuthVerifier, await + issueToken({idToken: true}))).rejects.toThrow("ID token is not"); + }); + + async function issueToken(options: { + readonly audience?: string | string[]; + readonly expiresAt?: number; + readonly idToken?: boolean; + readonly issuer?: string; + readonly profile?: boolean; + readonly signingKey?: CryptoKey; + readonly subject?: string | null; + }): Promise { + const claims = options.profile === false + ? {azp: "artifact-server", scope: "openid profile email"} + : { + azp: "artifact-server", + email: userEmail, + email_verified: true, + name: "Artifact Administrator", + preferred_username: "administrator", + scope: "openid profile email", + }; + const token = new SignJWT( + options.idToken === true ? {...claims, typ: "ID"} : claims, + ) + .setProtectedHeader({alg: "RS256", kid: keyId}) + .setIssuer(options.issuer ?? issuer) + .setAudience(options.audience ?? resource) + .setIssuedAt() + .setExpirationTime( + options.expiresAt ?? Math.floor(Date.now() / 1_000) + 300, + ); + if (options.subject !== null) token.setSubject(options.subject ?? subject); + return token.sign(options.signingKey ?? privateKey); + } + + function mcpDiscovery(token: string | null): Promise { + const headers = new Headers({ + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + "MCP-Protocol-Version": protocolVersion, + "Mcp-Method": "server/discover", + }); + if (token !== null) headers.set("Authorization", `Bearer ${token}`); + return fetch(`${server.baseUrl}/mcp`, { + body: JSON.stringify({ + id: crypto.randomUUID(), + jsonrpc: "2.0", + method: "server/discover", + params: { + _meta: { + [CLIENT_CAPABILITIES_META_KEY]: {}, + [CLIENT_INFO_META_KEY]: {name: "oidc-auth-test", version: "1"}, + [PROTOCOL_VERSION_META_KEY]: protocolVersion, + }, + }, + }), + headers, + method: "POST", + }); + } +}); + +function verifiedSubject( + verifier: ExternalMcpBearerVerifier, + token: string, +): Promise { + return Effect.runPromise( + verifier.verify(Redacted.make(token)).pipe( + Effect.map((verified) => verified.subject), + ), + ); +} + +interface ProviderBoundary { + readonly issuer: string; + close(): Promise; + setKeysStatus(status: number): void; + setUserInfoStatus(status: number): void; + userInfoRequests(): number; +} + +async function startProviderBoundary(options: { + readonly jwk: JWK; +}): Promise { + let keysStatus = 200; + let userInfoRequestCount = 0; + let userInfoStatus = 200; + let issuer = ""; + const provider = createServer((request, response) => { + if (request.url === `${realmPath}/.well-known/openid-configuration`) { + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(discoveryDocument(issuer))); + return; + } + if (request.url === `${realmPath}/protocol/openid-connect/certs`) { + if (keysStatus !== 200) { + response.statusCode = keysStatus; + response.end(); + return; + } + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({keys: [options.jwk]})); + return; + } + if (request.url === `${realmPath}/protocol/openid-connect/userinfo`) { + userInfoRequestCount += 1; + if (userInfoStatus !== 200) { + response.statusCode = userInfoStatus; + response.end(); + return; + } + if (request.headers.authorization?.startsWith("Bearer ") !== true) { + response.statusCode = 401; + response.end(); + return; + } + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ + email: userEmail, + email_verified: true, + family_name: "Administrator", + given_name: "Artifact", + sub: subject, + })); + return; + } + response.statusCode = 404; + response.end(); + }); + await listen(provider); + const address = provider.address(); + if (address === null || Predicate.isString(address)) { + throw new Error("The OIDC test boundary did not bind a TCP port."); + } + issuer = `http://127.0.0.1:${address.port}${realmPath}`; + return { + close: () => close(provider), + issuer, + setKeysStatus: (status) => { + keysStatus = status; + }, + setUserInfoStatus: (status) => { + userInfoStatus = status; + }, + userInfoRequests: () => userInfoRequestCount, + }; +} + +async function exportedJwk(keyId: string): Promise { + const keys = await generateKeyPair("RS256"); + const publicJwk = await exportJWK(keys.publicKey); + return {...publicJwk, alg: "RS256", kid: keyId, use: "sig"}; +} + +function discoveryDocument(issuer: string) { + return { + authorization_endpoint: `${issuer}/protocol/openid-connect/auth`, + code_challenge_methods_supported: ["S256"], + grant_types_supported: ["authorization_code", "refresh_token"], + issuer, + jwks_uri: `${issuer}/protocol/openid-connect/certs`, + registration_endpoint: `${issuer}/clients-registrations/openid-connect`, + response_types_supported: ["code"], + scopes_supported: ["openid", "profile", "email"], + token_endpoint: `${issuer}/protocol/openid-connect/token`, + userinfo_endpoint: `${issuer}/protocol/openid-connect/userinfo`, + }; +} + +type DiscoveryDocument = ReturnType; + +function documentResponse(document: DiscoveryDocument): Response { + return new Response(JSON.stringify(document), { + headers: {"Content-Type": "application/json"}, + status: 200, + }); +} + +function listen(server: Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => error === undefined ? resolve() : reject(error)); + }); +} diff --git a/tests/integration/oidc-keycloak.test.ts b/tests/integration/oidc-keycloak.test.ts index 05b8b93..8ddfa55 100644 --- a/tests/integration/oidc-keycloak.test.ts +++ b/tests/integration/oidc-keycloak.test.ts @@ -9,8 +9,8 @@ import {z} from "zod"; import {privateTeamBrowserAccess, browserLoginKinds} from "../../src/core/browser-access.js"; -import {createOidcIdentityProvider} from - "../../src/identity/oidc-identity-provider.js"; +import {createOidcHostedAuthentication} from + "../../src/identity/oidc-hosted-authentication.js"; import { loginHandshakeCookie, reserveLoopbackPort, @@ -19,6 +19,10 @@ import { const realmName = "artifact-server"; const oidcClientId = "artifact-server-integration"; +const mcpClientId = "artifact-server-mcp-integration"; +const mcpClientSecret = "keycloak-integration-only-mcp-secret"; +const unboundClientId = "artifact-server-unbound-integration"; +const unboundClientSecret = "keycloak-integration-only-unbound-secret"; const oidcClientSecret = "keycloak-integration-only-client-secret"; const oidcScopes = "openid email profile"; const admittedEmail = "admitted@example.test"; @@ -84,9 +88,17 @@ interface KeycloakRealmRepresentation { readonly realm: string; } +interface KeycloakProtocolMapperRepresentation { + readonly config: Readonly>; + readonly name: string; + readonly protocol: string; + readonly protocolMapper: string; +} + interface KeycloakClientRepresentation { readonly attributes: {readonly "pkce.code.challenge.method": string}; readonly clientId: string; + readonly protocolMappers?: readonly KeycloakProtocolMapperRepresentation[]; readonly directAccessGrantsEnabled: boolean; readonly enabled: boolean; readonly protocol: string; @@ -238,8 +250,115 @@ describe.sequential("Keycloak generic OIDC browser login", () => { expect(rowCount(application.dataDirectory, "installation_members")).toBe(1); expect(rowCount(application.dataDirectory, "application_sessions")).toBe(1); }); + + test("a real Keycloak access token bound to /mcp authorizes the MCP endpoint", async () => { + const bound = await requestPasswordToken(realm.issuer, mcpClientId, mcpClientSecret, { + password: admittedPassword, + username: admittedEmail, + }); + const authorized = await callMcp(application.baseUrl, bound); + expect(authorized.status).toBe(200); + expect(await readMcpToolNames(authorized)).toContain("artifact_capabilities"); + + const unbound = await requestPasswordToken( + realm.issuer, + unboundClientId, + unboundClientSecret, + { + password: admittedPassword, + username: admittedEmail, + }, + ); + const refused = await callMcp(application.baseUrl, unbound); + expect(refused.status).toBe(401); + + const missing = await callMcp(application.baseUrl, null); + expect(missing.status).toBe(401); + expect(missing.headers.get("www-authenticate")).toContain( + `resource_metadata="${application.baseUrl}/.well-known/oauth-protected-resource/mcp"`, + ); + + const metadata = await fetch( + `${application.baseUrl}/.well-known/oauth-protected-resource/mcp`, + ); + expect(metadata.status).toBe(200); + expect(await metadata.json()).toMatchObject({ + authorization_servers: [realm.issuer], + resource: `${application.baseUrl}/mcp`, + }); + }); + + test("a Keycloak identity that was never admitted is refused at MCP too", async () => { + const stranger = await requestPasswordToken(realm.issuer, mcpClientId, mcpClientSecret, { + password: strangerPassword, + username: strangerEmail, + }); + const refused = await callMcp(application.baseUrl, stranger); + expect(refused.status).toBe(401); + expect(rowCount(application.dataDirectory, "installation_members")).toBe(1); + }); }); +async function requestPasswordToken( + issuer: string, + clientId: string, + clientSecret: string, + credentials: KeycloakCredentials, +): Promise { + const response = await fetch( + `${issuer}/protocol/openid-connect/token`, + { + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "password", + password: credentials.password, + scope: "openid email profile", + username: credentials.username, + }), + headers: {"Content-Type": "application/x-www-form-urlencoded"}, + method: "POST", + }, + ); + if (!response.ok) { + throw new Error( + `Keycloak refused the password grant: ${response.status} ${await response.text()}`, + ); + } + return z.object({access_token: z.string().min(1)}) + .parse(await response.json()).access_token; +} + +function callMcp(baseUrl: string, token: string | null): Promise { + const headers = new Headers({ + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + }); + if (token !== null) headers.set("Authorization", `Bearer ${token}`); + return fetch(`${baseUrl}/mcp`, { + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "tools/list", + params: {}, + }), + headers, + method: "POST", + }); +} + +async function readMcpToolNames(response: Response): Promise { + const body = await response.text(); + const payload = body.split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => line.slice(6)) + .at(-1) ?? body; + const parsed: unknown = JSON.parse(payload); + return z.object({ + result: z.object({tools: z.array(z.object({name: z.string()}))}), + }).parse(parsed).result.tools.map((tool) => tool.name); +} + function readKeycloakEnvironment(): KeycloakEnvironment { const adminPassword = process.env["ARTIFACT_SERVER_TEST_KEYCLOAK_ADMIN_PASSWORD"]; const adminUser = process.env["ARTIFACT_SERVER_TEST_KEYCLOAK_ADMIN_USER"]; @@ -280,6 +399,54 @@ async function provisionKeycloakRealm( webOrigins: [applicationOrigin], }, ); + await adminRequest( + environment, + token, + "POST", + `/admin/realms/${realmName}/clients`, + { + attributes: {"pkce.code.challenge.method": "S256"}, + clientId: mcpClientId, + directAccessGrantsEnabled: true, + enabled: true, + protocol: "openid-connect", + protocolMappers: [{ + config: { + "access.token.claim": "true", + "id.token.claim": "false", + "included.custom.audience": `${applicationOrigin}/mcp`, + }, + name: "mcp audience", + protocol: "openid-connect", + protocolMapper: "oidc-audience-mapper", + }], + publicClient: false, + redirectUris: [`${applicationOrigin}/auth/callback`], + secret: mcpClientSecret, + serviceAccountsEnabled: false, + standardFlowEnabled: true, + webOrigins: [applicationOrigin], + }, + ); + await adminRequest( + environment, + token, + "POST", + `/admin/realms/${realmName}/clients`, + { + attributes: {"pkce.code.challenge.method": "S256"}, + clientId: unboundClientId, + directAccessGrantsEnabled: true, + enabled: true, + protocol: "openid-connect", + publicClient: false, + redirectUris: [`${applicationOrigin}/auth/callback`], + secret: unboundClientSecret, + serviceAccountsEnabled: false, + standardFlowEnabled: true, + webOrigins: [applicationOrigin], + }, + ); const admittedSubject = await createKeycloakUser(environment, token, { email: admittedEmail, firstName: "Ada", @@ -479,6 +646,13 @@ async function startApplicationProcess( const dataDirectory = await mkdtemp( path.join(tmpdir(), "artifact-server-oidc-"), ); + const hosted = await createOidcHostedAuthentication({ + applicationOrigin, + clientId: oidcClientId, + clientSecret: Redacted.make(oidcClientSecret, {label: "oidc-client-secret"}), + issuer, + scopes: oidcScopes, + }); const server = await startTestServer({ apiToken: "as_key_key_00000000-0000-4000-8000-000000000002_oidcIntegrationMachineCredential12345", @@ -488,13 +662,9 @@ async function startApplicationProcess( applicationOrigin, bootstrapAdministratorEmail: admittedEmail, browserAccess: privateTeamBrowserAccess(browserLoginKinds.oidc), - interactiveIdentityProvider: createOidcIdentityProvider({ - applicationOrigin, - clientId: oidcClientId, - clientSecret: Redacted.make(oidcClientSecret, {label: "oidc-client-secret"}), - issuer, - scopes: oidcScopes, - }), + externalMcpOAuthVerifier: hosted.externalMcpOAuthVerifier, + interactiveIdentityProvider: hosted.interactiveIdentityProvider, + mcpOAuthResource: hosted.mcpOAuthResource, port, }); return { From 0d637ea8a6231fbbb7f1feb325795382e604888a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 22 Sep 2026 14:31:53 -0700 Subject: [PATCH 2/3] Refuse non-access-token JWTs and unverified emails at /mcp The ID-token refusal only recognized Keycloak's payload typ of ID. A JWT the issuer signed with nonce, at_hash, or c_hash, or with a JOSE header typ other than at+jwt or JWT (logout+jwt, secevent+jwt, ID-JAG), is now refused as well, following RFC 9068 section 4 and RFC 8725 explicit typing. at+jwt, application/at+jwt, JWT, and a missing typ stay accepted. On the MCP bearer path an email links a member or claims the bootstrap administrator only when email_verified is true. A subject that is already bound is still recognized by issuer and subject alone. Browser login is unchanged. Conformance tests now carry MCP-013-B and MCP-013-F, and the Keycloak harness proves that the real browser session cookie is refused at /mcp. Claude-Session: https://claude.ai/code/session_01EcbEctwH1oDTrYhqHe6n9g --- src/identity/oidc-mcp-bearer-verifier.ts | 39 ++++++- src/identity/oidc-oauth-metadata.ts | 6 +- .../oidc-mcp-authorization.test.ts | 105 +++++++++++++++--- tests/integration/oidc-keycloak.test.ts | 11 +- 4 files changed, 140 insertions(+), 21 deletions(-) diff --git a/src/identity/oidc-mcp-bearer-verifier.ts b/src/identity/oidc-mcp-bearer-verifier.ts index 516272d..9a1a8e2 100644 --- a/src/identity/oidc-mcp-bearer-verifier.ts +++ b/src/identity/oidc-mcp-bearer-verifier.ts @@ -3,6 +3,7 @@ import { customFetch, errors as joseErrors, jwtVerify, + type JWTHeaderParameters, type JWTPayload, } from "jose"; import {Effect, Predicate, Redacted, Schema} from "effect"; @@ -20,6 +21,11 @@ import {requireOidcIssuer} from "./oidc-issuer.js"; const defaultAlgorithms = ["RS256", "ES256"]; const idTokenType = "ID"; +// OIDC defines these for ID tokens only; an access token has no use for them. +const idTokenOnlyClaims = ["nonce", "at_hash", "c_hash"] as const; +// RFC 9068 access tokens say at+jwt. Many issuers, Keycloak among them, still +// say JWT or leave the header out, so those stay accepted too. +const accessTokenTypes = new Set(["at+jwt", "jwt"]); const clockTolerance = "30s"; const jwksCacheMilliseconds = 10 * 60 * 1_000; const jwksCooldownMilliseconds = 5 * 60 * 1_000; @@ -149,10 +155,14 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { clockTolerance, issuer: this.#issuer, }); - return result.payload; + return result; }, catch: (cause) => verificationFailure(cause), - }).pipe(Effect.flatMap(decodeClaims)); + }).pipe( + Effect.flatMap(({payload, protectedHeader}) => + decodeClaims(payload, protectedHeader) + ), + ); } readonly #userInfoIdentity = Effect.fn("OidcMcpBearerVerifier.userInfo")( @@ -220,7 +230,10 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { return { displayName: displayName(claims, email), email, - emailVerified: claims.email_verified !== false, + // Unlike browser login, a missing claim is not a verified address: an + // access-token email may be a mutable profile field, and on first use it + // can link an admitted member or claim the bootstrap administrator. + emailVerified: claims.email_verified === true, provider: this.#provider, subject: claims.sub, }; @@ -229,14 +242,24 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { function decodeClaims( payload: JWTPayload, + header: JWTHeaderParameters, ): Effect.Effect { + // An ID token, logout token, or any other JWT from the same issuer is not a + // credential for this endpoint even when it names the same audience. + if (!isAccessTokenType(header.typ)) { + return Effect.fail(invalidToken("The JWT type is not an access token.")); + } + if (idTokenOnlyClaims.some((claim) => claim in payload)) { + return Effect.fail(invalidToken( + "An ID token is not an Artifact Server MCP credential.", + )); + } return decodeAccessTokenClaims(payload).pipe( Effect.mapError(() => invalidToken( "The OIDC access token is missing required claims.", )), Effect.flatMap((claims) => - // Keycloak marks ID tokens with typ ID, and an ID token is not a - // credential for this endpoint even when it names the same audience. + // Keycloak also marks its ID tokens with a payload typ of ID. claims.typ === idTokenType ? Effect.fail(invalidToken( "An ID token is not an Artifact Server MCP credential.", @@ -246,6 +269,12 @@ function decodeClaims( ); } +/** Media types compare case-insensitively, and `application/` is optional. */ +function isAccessTokenType(typ: string | undefined): boolean { + if (typ === undefined) return true; + return accessTokenTypes.has(typ.toLowerCase().replace(/^application\//u, "")); +} + function emailOf(claims: ProfileClaims): string | null { const email = claims.email?.trim() ?? ""; return email === "" ? null : email; diff --git a/src/identity/oidc-oauth-metadata.ts b/src/identity/oidc-oauth-metadata.ts index ef8e0c1..9647bf5 100644 --- a/src/identity/oidc-oauth-metadata.ts +++ b/src/identity/oidc-oauth-metadata.ts @@ -76,8 +76,10 @@ export async function loadOidcAuthorizationServer( "userinfo endpoint", allowLocalHttp, ); - // These two are served on to MCP clients, so a client must not be sent - // anywhere this server would have refused to go itself. + // The document is served back as the issuer publishes it, and an MCP client + // reads the same document from the issuer anyway. These two endpoints are + // still checked, because a client sends its registration and its tokens to + // them, and this server must not advertise one it would refuse to use. const registrationEndpoint = optionalEndpoint( document.registration_endpoint, "registration endpoint", diff --git a/tests/conformance/oidc-mcp-authorization.test.ts b/tests/conformance/oidc-mcp-authorization.test.ts index 948f8cd..fab1572 100644 --- a/tests/conformance/oidc-mcp-authorization.test.ts +++ b/tests/conformance/oidc-mcp-authorization.test.ts @@ -11,6 +11,7 @@ import { SignJWT, type CryptoKey, type JWK, + type JWTPayload, } from "jose"; import {Effect, Predicate, Redacted} from "effect"; import {afterEach, beforeEach, describe, expect, test} from "vitest"; @@ -114,7 +115,7 @@ describe("generic OIDC MCP authorization", () => { })).rejects.toThrow("registration endpoint"); }); - test("an end-user token authenticates once and keeps its own membership", async () => { + test("MCP-013-B: an end-user token authenticates once and keeps its own membership", async () => { expect.hasAssertions(); const protectedMetadata = await fetch( `${server.baseUrl}/.well-known/oauth-protected-resource/mcp`, @@ -212,7 +213,7 @@ describe("generic OIDC MCP authorization", () => { } }); - test("wrong token contracts fail closed and issuer outages stay distinct", async () => { + test("MCP-013-F: wrong token contracts fail closed and issuer outages stay distinct", async () => { expect.hasAssertions(); expect((await mcpDiscovery(await issueToken({ audience: "https://attacker.example/mcp", @@ -239,6 +240,21 @@ describe("generic OIDC MCP authorization", () => { expect((await mcpDiscovery(await issueToken({idToken: true}))).status) .toBe(401); + // An ID token from an issuer that does not mark it with a payload typ, + // minted for a client whose ID happens to be the MCP URL, still carries + // claims only an ID token has. + const idTokenClaims = ["nonce", "at_hash", "c_hash"]; + expect(await discoveryStatuses(idTokenClaims.map((claim) => ({ + extraClaims: {auth_time: Math.floor(Date.now() / 1_000), [claim]: "x"}, + type: "JWT", + })))).toEqual(idTokenClaims.map(() => 401)); + + // Any other explicitly typed JWT from the same issuer is not an access + // token either. + const otherTypes = ["logout+jwt", "secevent+jwt", "oauth-id-jag+jwt", "id+jwt"]; + expect(await discoveryStatuses(otherTypes.map((type) => ({type})))) + .toEqual(otherTypes.map(() => 401)); + provider.setUserInfoStatus(401); expect((await mcpDiscovery(await issueToken({profile: false}))).status) .toBe(401); @@ -247,6 +263,47 @@ describe("generic OIDC MCP authorization", () => { .toBe(500); }); + test("the access-token types issuers really send are accepted", async () => { + expect.hasAssertions(); + // RFC 9068 says at+jwt; Keycloak and many others say JWT or nothing. + // The first request binds the member, so the rest can run together. + expect((await mcpDiscovery(await issueToken({}))).status).toBe(200); + const types = ["at+jwt", "application/at+jwt", "AT+JWT", "JWT"]; + expect(await discoveryStatuses(types.map((type) => ({type})))) + .toEqual(types.map(() => 200)); + }); + + test("an unverified email cannot claim the administrator or link to a member", async () => { + expect.hasAssertions(); + const intruder = "0b7e7f7c-0000-4000-8000-intruder"; + + // On a fresh installation the bootstrap email would admit an administrator. + expect((await mcpDiscovery(await issueToken({ + emailVerified: null, + subject: intruder, + }))).status).toBe(401); + expect((await mcpDiscovery(await issueToken({ + emailVerified: false, + subject: intruder, + }))).status).toBe(401); + + // The real person arrives with a verified address and becomes the member. + expect((await mcpDiscovery(await issueToken({}))).status).toBe(200); + + // The same unverified address still cannot link to that admitted member. + expect((await mcpDiscovery(await issueToken({ + emailVerified: null, + subject: intruder, + }))).status).toBe(401); + + // A subject that is already bound is known by issuer and subject alone. + expect((await mcpDiscovery(await issueToken({emailVerified: null}))) + .status).toBe(200); + expect((await mcpDiscovery(await issueToken({profile: false}))).status) + .toBe(200); + expect(provider.userInfoRequests()).toBe(0); + }); + test("a token that names several audiences is accepted", async () => { expect.hasAssertions(); // Keycloak names account beside the requested audience, so membership of @@ -282,27 +339,40 @@ describe("generic OIDC MCP authorization", () => { async function issueToken(options: { readonly audience?: string | string[]; + /** `null` leaves the claim out. */ + readonly emailVerified?: boolean | null; readonly expiresAt?: number; + readonly extraClaims?: Readonly>; readonly idToken?: boolean; readonly issuer?: string; readonly profile?: boolean; readonly signingKey?: CryptoKey; readonly subject?: string | null; + /** The JOSE header `typ`; left out unless given. */ + readonly type?: string; }): Promise { - const claims = options.profile === false - ? {azp: "artifact-server", scope: "openid profile email"} - : { - azp: "artifact-server", + const claims: JWTPayload = { + azp: "artifact-server", + scope: "openid profile email", + }; + if (options.profile !== false) { + Object.assign(claims, { email: userEmail, - email_verified: true, name: "Artifact Administrator", preferred_username: "administrator", - scope: "openid profile email", - }; - const token = new SignJWT( - options.idToken === true ? {...claims, typ: "ID"} : claims, - ) - .setProtectedHeader({alg: "RS256", kid: keyId}) + }); + if (options.emailVerified !== null) { + claims["email_verified"] = options.emailVerified ?? true; + } + } + if (options.extraClaims !== undefined) { + Object.assign(claims, options.extraClaims); + } + if (options.idToken === true) claims["typ"] = "ID"; + const token = new SignJWT(claims) + .setProtectedHeader(options.type === undefined + ? {alg: "RS256", kid: keyId} + : {alg: "RS256", kid: keyId, typ: options.type}) .setIssuer(options.issuer ?? issuer) .setAudience(options.audience ?? resource) .setIssuedAt() @@ -313,6 +383,15 @@ describe("generic OIDC MCP authorization", () => { return token.sign(options.signingKey ?? privateKey); } + /** Present one token per variant at once; each is judged on its own. */ + async function discoveryStatuses( + variants: readonly Parameters[0][], + ): Promise { + const tokens = await Promise.all(variants.map(issueToken)); + const responses = await Promise.all(tokens.map(mcpDiscovery)); + return responses.map((response) => response.status); + } + function mcpDiscovery(token: string | null): Promise { const headers = new Headers({ Accept: "application/json, text/event-stream", diff --git a/tests/integration/oidc-keycloak.test.ts b/tests/integration/oidc-keycloak.test.ts index 8ddfa55..e68404f 100644 --- a/tests/integration/oidc-keycloak.test.ts +++ b/tests/integration/oidc-keycloak.test.ts @@ -203,6 +203,10 @@ describe.sequential("Keycloak generic OIDC browser login", () => { expect(session.status).toBe(200); const principal = sessionResponseSchema.parse(await session.json()).principal; + // The real browser session is not an MCP credential. + const cookieOnly = await callMcp(application.baseUrl, null, applicationCookies); + expect(cookieOnly.status).toBe(401); + const members = await fetch(`${application.baseUrl}/api/v1/members`, { headers: {Cookie: applicationCookies}, }); @@ -329,12 +333,17 @@ async function requestPasswordToken( .parse(await response.json()).access_token; } -function callMcp(baseUrl: string, token: string | null): Promise { +function callMcp( + baseUrl: string, + token: string | null, + cookie?: string, +): Promise { const headers = new Headers({ Accept: "application/json, text/event-stream", "Content-Type": "application/json", }); if (token !== null) headers.set("Authorization", `Bearer ${token}`); + if (cookie !== undefined) headers.set("Cookie", cookie); return fetch(`${baseUrl}/mcp`, { body: JSON.stringify({ id: 1, From f2a8ed7ff129c58ae9fdb5a6c35b23d9dd405b51 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 22 Sep 2026 14:31:53 -0700 Subject: [PATCH 3/3] Record the MCP token rules and correct the Entra guidance Decision 0028 now records the access-token type checks and why the MCP path requires email_verified when browser login does not. The deployment guide drops the Entra audience claim, which v2.0 tokens cannot satisfy, and notes that MCP OAuth stays off after a startup discovery failure. The MCP-013 proof gap states that the Keycloak harness uses the password grant. Claude-Session: https://claude.ai/code/session_01EcbEctwH1oDTrYhqHe6n9g --- docs/deployment.md | 23 +++++++++--- project/spec/conformance.yml | 2 +- .../spec/decisions/0020-generic-oidc-login.md | 2 +- project/spec/decisions/0028-oidc-mcp-oauth.md | 35 ++++++++++++++++++- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index b9063ed..873fcbd 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -57,9 +57,18 @@ The issuer must provide four things: The audience is the one step an operator must configure. Providers do not bind a resource URL on their own. In Keycloak, add a client scope with an audience mapper whose included custom audience is that exact URL, and assign the scope to -the client the agents use. Entra exposes an API and uses its application ID URI. -Okta sets the audience on a custom authorization server. A provider that -supports RFC 8707 resource indicators can bind it per request instead. +the client the agents use. Okta sets the audience on a custom authorization +server. A provider that supports RFC 8707 resource indicators can bind it per +request instead. + +Microsoft Entra ID cannot protect `/mcp` this way. Its v2.0 access tokens name +the API's client ID in `aud`, not a URL, and its userinfo endpoint accepts only +Microsoft Graph tokens. Entra installations keep browser login and use API keys +for MCP. + +On first use, the token or the issuer's userinfo response must carry the +person's `email` with `email_verified: true`. A person who already signed in +through the browser is recognized by issuer and subject and needs neither. Register the client the agents use in one of two supported ways: @@ -74,7 +83,13 @@ unauthenticated MCP request with `401` and a `resource_metadata` challenge, so a compliant client finds the issuer without further configuration. Clients that cannot complete OAuth keep using administration-issued API keys. -Tokens that name another resource, and ID tokens, are refused. +Tokens that name another resource are refused. ID tokens and other JWTs that are +not access tokens are refused too: a JOSE `typ` other than `at+jwt` or `JWT`, +or an ID-token claim such as `nonce` or `at_hash`. + +The server reads the issuer's discovery document once at startup. If the issuer +cannot be reached then, the server logs a warning and starts with browser login +and API keys only. MCP OAuth stays off until the next restart. ## Back up the installation diff --git a/project/spec/conformance.yml b/project/spec/conformance.yml index 09bbe3c..7e009b1 100644 --- a/project/spec/conformance.yml +++ b/project/spec/conformance.yml @@ -1972,7 +1972,7 @@ requirements: failure: {id: MCP-013-F, description: "Startup logs, diagnostics, project files, generic OIDC ID tokens, browser login cookies, and wrong-resource tokens cannot expose or substitute for a valid MCP credential."} deployments: [local, single_server, kubernetes, aws, gcp] status: implementing - proof_gap: Local stdio, API-key, and generic OIDC access-token paths are implemented and covered by the local conformance suite and the real Keycloak harness, but the combined private OAuth, no-OAuth, local, wrong-credential, and credential-leak matrix has no deployment-specific MCP-013 evidence. + proof_gap: Local stdio, API-key, and generic OIDC access-token paths are implemented and covered by the local conformance suite and the real Keycloak harness, but the combined private OAuth, no-OAuth, local, wrong-credential, and credential-leak matrix has no deployment-specific MCP-013 evidence. The Keycloak harness obtains its MCP access tokens through the password grant; no MCP client has yet completed browser approval (authorization code with S256 PKCE, client registration, and the RFC 8707 resource parameter) against a generic OIDC issuer, so the browser-approval half of MCP-013-B is unproved for OIDC. depends_on: [AUTH-009] evidence: [] diff --git a/project/spec/decisions/0020-generic-oidc-login.md b/project/spec/decisions/0020-generic-oidc-login.md index d50e19d..f77cd43 100644 --- a/project/spec/decisions/0020-generic-oidc-login.md +++ b/project/spec/decisions/0020-generic-oidc-login.md @@ -104,7 +104,7 @@ the end-session endpoint. register against and whose tokens it can introspect, and that a bare enterprise identity provider is generally not that server. Introspection was never part of the path: the WorkOS verifier reads a JWT against a discovered - JWKS, which a Keycloak, Entra, or Okta access token supports as well. + JWKS, which a Keycloak or Okta access token supports as well. Registration is an operator step rather than a protocol gap. 0028 records what an issuer must provide instead. - Directory sync, SCIM, role and group mapping, and provisioning beyond the diff --git a/project/spec/decisions/0028-oidc-mcp-oauth.md b/project/spec/decisions/0028-oidc-mcp-oauth.md index 4e08900..e9a6141 100644 --- a/project/spec/decisions/0028-oidc-mcp-oauth.md +++ b/project/spec/decisions/0028-oidc-mcp-oauth.md @@ -58,8 +58,41 @@ indicator, and the deployment guide says so. ### An ID token is not an MCP credential -A payload `typ` of `ID` is refused. Keycloak marks its ID tokens that way, and MCP-013-F requires that an ID token cannot substitute for an MCP credential. +Audience binding refuses most ID tokens, because their `aud` is a client ID. +It does not refuse them when an operator names a client after the resource +URL, so the verifier also refuses a JWT that is typed or shaped as something +other than an access token: + +- a JOSE header `typ` that is present and is not `at+jwt` or `JWT`, compared + case-insensitively with an optional `application/` prefix. RFC 9068 `at+jwt` + is the preferred type; `JWT` and a missing header stay accepted because + Keycloak and many other issuers still send them. This refuses logout tokens, + security event tokens, and ID-JAG assertions from the same issuer; +- a payload that carries `nonce`, `at_hash`, or `c_hash`, which OIDC defines + for ID tokens only; +- a payload `typ` of `ID`, which Keycloak writes into its ID tokens. + +The MCP specification itself only requires OAuth 2.1 resource-server +validation and audience binding. These checks follow RFC 9068 section 4 and +the explicit-typing advice of RFC 8725 so that the ledger promise holds for +every issuer, not only Keycloak. + +### An access token's email must be verified before it links a member + +Browser login treats a missing `email_verified` claim as verified, and +decision 0020 keeps that rule. The MCP path does not: an access token, or the +userinfo profile it falls back to, must carry `email_verified: true` before its +email can link the token to an admitted member or claim the bootstrap +administrator on a fresh installation. Otherwise the admission gate refuses it. + +The paths differ because their risks differ. Access-token profile claims are +often configurable fields, not verified addresses. Entra's optional `email` +claim, for example, is mutable and arrives with no `email_verified` claim. On +this path the first presented token can bind the bootstrap administrator, and +any client registered at the issuer, including a dynamically registered one, +can present such a token. A subject that is already bound, by an earlier browser login or MCP +token, is recognized by `oidc:` and `sub` alone and needs no email. ### An issuer that cannot serve its keys is unavailable, not a bad token