From b51e7bee3052c6168d05eb508af8156673a2f823 Mon Sep 17 00:00:00 2001 From: yalait Date: Mon, 14 Sep 2026 02:51:25 +0700 Subject: [PATCH] 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 {