From de94df3fe70d09c0bb232d026f84bdb60a707647 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 25 Sep 2026 11:33:31 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(mcp)=20make=20the=20docs-mcp?= =?UTF-8?q?=20server=20OIDC=20provider-agnostic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Django side of the MCP auth chain is already settings-driven, but the docs-mcp server was tied to Keycloak: KEYCLOAK_* env vars and a mandatory `aud=docs-mcp`, which needs a Keycloak audience mapper. The provider settings are now MCP_OIDC_ISSUER, MCP_OIDC_JWKS_URL and MCP_OIDC_DISCOVERY_URL. The audience check mirrors the backend's OIDC_RS_AUDIENCE_CLAIM: MCP_AUDIENCE_CLAIM picks the claim to check (`aud` by default, or `client_id` / `azp`) and MCP_ALLOWED_AUDIENCES lists the accepted values. The Keycloak-only `docs-mcp` scope is no longer hardcoded in the protected resource metadata; it moves to MCP_EXTRA_SCOPES. The documentation now lists what any OIDC provider must provide, with the Keycloak realm kept as the development example. --- documentation/mcp_server.md | 200 +++++++++++----- env.d/development/mcp | 24 +- .../servers/mcp/__tests__/jwtVerifier.test.ts | 216 ++++++++++++++++++ .../servers/mcp/__tests__/server.test.ts | 79 ++++++- .../servers/mcp/src/auth/jwtVerifier.ts | 68 +++++- src/frontend/servers/mcp/src/docsApiClient.ts | 2 +- src/frontend/servers/mcp/src/env.ts | 52 ++++- src/frontend/servers/mcp/src/prm.ts | 28 ++- src/frontend/servers/mcp/src/server.ts | 18 +- 9 files changed, 573 insertions(+), 114 deletions(-) create mode 100644 src/frontend/servers/mcp/__tests__/jwtVerifier.test.ts diff --git a/documentation/mcp_server.md b/documentation/mcp_server.md index 5fead8cbd..309d0108e 100644 --- a/documentation/mcp_server.md +++ b/documentation/mcp_server.md @@ -2,32 +2,41 @@ `docs-mcp` is a remote [MCP](https://modelcontextprotocol.io) server that exposes three tools — `search_documents`, `read_document`, `create_document` — backed by the Docs API. -It is an OAuth-protected resource server: it authenticates the caller via Keycloak and forwards -the caller's own token to Django, which stays the sole authority on document permissions. +It is an OAuth-protected resource server: it authenticates the caller with an access token from +your OIDC provider and forwards the caller's own token to Django, which stays the sole authority +on document permissions. + +Nothing in the auth chain is tied to a specific identity provider: both `docs-mcp` and the Django +endpoints are configured through settings only. The development stack uses Keycloak +(`docker/auth/realm.json`) as a ready-made example, see +[Example: the development Keycloak realm](#example-the-development-keycloak-realm). ## Architecture ```text MCP client (MCP Inspector, Claude Code, ...) | - | 1. Authorization Code + PKCE, requesting scopes: - | openid docs:documents:search docs:documents:read docs:documents:create docs-mcp + | 1. Authorization Code + PKCE against the OIDC provider, requesting scopes: + | openid docs:documents:search docs:documents:read docs:documents:create + | (+ any MCP_EXTRA_SCOPES, e.g. docs-mcp in dev) v -Keycloak (impress realm) +OIDC provider (dev: Keycloak, impress realm) | - | 2. Access token, aud=docs-mcp (only because the docs-mcp scope was requested), - | azp=docs-mcp-client + | 2. JWT access token identifying docs-mcp through MCP_AUDIENCE_CLAIM + | (dev: aud=docs-mcp, azp=docs-mcp-client) v docs-mcp (TypeScript, Streamable HTTP, stateless) | - | 3. Forwards the *same* access token, unchanged, as a Bearer token + | 3. Verifies the JWT locally (JWKS, iss, exp, audience claim), then forwards the *same* + | access token, unchanged, as a Bearer token | (no token exchange; this server holds no credentials of its own) v Django /api/v1.0/mcp/documents/* (core/mcp_api) | - | 4. ResourceServerAuthentication (django-lasuite) introspects the token at Keycloak, - | checks its origin client is allow-listed (OIDC_RS_ALLOWED_AUDIENCES=docs-mcp-client), - | resolves the Docs user by `sub`, DocumentViewSet's permissions/querysets decide + | 4. ResourceServerAuthentication (django-lasuite) introspects the token at the provider, + | checks its origin client is allow-listed (OIDC_RS_AUDIENCE_CLAIM in + | OIDC_RS_ALLOWED_AUDIENCES), resolves the Docs user by `sub`, + | DocumentViewSet's permissions/querysets decide v PostgreSQL ``` @@ -44,68 +53,142 @@ There is deliberately no RFC 8693 (Token Exchange) step: local JWT validation (RFC 9068 + JWKS, RFC 7517) plus plain token forwarding (RFC 6750) needs no client secret, so this server holds no credentials of its own. +The bundled `docs-mcp` server is optional: the Django endpoints only rely on +`ResourceServerAuthentication` and `MCPResourceServerPermission`, so any other MCP server (or +client) forwarding a token that passes the `OIDC_RS_*` checks can use them. + ## Files -- `docker/auth/realm.json` — Keycloak clients (`docs-mcp-client` public, `docs-api` - confidential/introspection) and client scopes (`docs:documents:search`, - `docs:documents:read`, `docs:documents:create`, `docs-mcp`). - `src/backend/core/mcp_api/` — the three Django endpoints and their permission/serializer classes. -- `src/backend/impress/settings.py`, `env.d/development/common` — reused `OIDC_RS_*` resource - server settings (`OIDC_RS_CLIENT_ID=docs-api` for introspection, - `OIDC_RS_ALLOWED_AUDIENCES=docs-mcp-client`); `MCP_READ_CONTENT_MAX_CHARS`. +- `src/backend/impress/settings.py`, `env.d/development/common` — reused `OIDC_OP_*` / + `OIDC_RS_*` resource server settings; `MCP_READ_CONTENT_MAX_CHARS`. - `src/frontend/servers/mcp/` — the TypeScript server (Express + `@modelcontextprotocol/sdk`). - Validates incoming tokens against Keycloak's JWKS (`src/auth/jwtVerifier.ts`) and forwards + Validates incoming tokens against the provider's JWKS (`src/auth/jwtVerifier.ts`) and forwards them to Django (`src/docsApiClient.ts`). - `env.d/development/mcp`, `compose.yml` (`mcp-development` service) — how it runs locally. +- `docker/auth/realm.json` — the development Keycloak realm (clients and client scopes below). - `Makefile` (`mcp-claude`/`mcp-codex`/`mcp-gemini`/`mcp-cursor` targets) — generate each client's local, gitignored config pinning `docs-mcp-client` on first run (see "Connecting other MCP clients" below). -## Environment variables (`src/frontend/servers/mcp`) +## OIDC provider requirements -| Variable | Purpose | -| --- | --- | -| `MCP_HOST` / `MCP_PORT` | bind address for the Express server | -| `MCP_RESOURCE_URL` | this server's `/mcp` URL, used as the OAuth "resource" and in PRM metadata | -| `KEYCLOAK_ISSUER` | issuer as seen by clients/tokens (`iss` claim check), e.g. `http://localhost:8083/realms/impress` | -| `KEYCLOAK_JWKS_URL` / `KEYCLOAK_DISCOVERY_URL` | JWKS and discovery endpoints, reachable from inside Docker (`http://nginx:8083/...`) | -| `DOCS_API_URL` | Django's base URL, where the caller's token is forwarded | -| `MCP_AUDIENCE` | audience this server requires on incoming tokens (`docs-mcp`) | +Any OpenID Connect provider works as long as it offers the following. Each item names the +setting(s) it maps to on the `docs-mcp` side (`MCP_*`) and on the Django side (`OIDC_*`). -This server holds no credentials: it only reads Keycloak's public JWKS to verify token -signatures and forwards the caller's token to Django. `KEYCLOAK_ISSUER` is split from the -`*_URL` variables because, in Docker, this server reaches Keycloak through the internal `nginx` -alias, while the token's `iss` claim (and the metadata shown to external clients) must use the -externally-visible `localhost:8083` hostname — the same split Django's own OIDC settings use +1. **Discovery and signing keys.** An issuer URL, an OIDC discovery document + (`/.well-known/openid-configuration`) and a JWKS endpoint (`MCP_OIDC_ISSUER`, + `MCP_OIDC_DISCOVERY_URL`, `MCP_OIDC_JWKS_URL`; `OIDC_OP_URL` for Django). `docs-mcp` + re-publishes the discovery document as its RFC 9728 protected resource metadata, so MCP + clients find the authorization and token endpoints there. The provider must support the + Authorization Code flow with PKCE (`S256`). +2. **JWT access tokens.** `docs-mcp` verifies tokens locally, so they must be JWTs signed with a + key from the JWKS, carrying `iss`, `exp`, `sub` and a space-separated `scope` claim. Opaque + access tokens are not supported by `docs-mcp` (see [Known limitations](#known-limitations)). + Django does not have this constraint: it introspects the token in any case. +3. **A claim identifying `docs-mcp`.** So that a token minted for another application of the + same provider is rejected, `docs-mcp` requires `MCP_AUDIENCE_CLAIM` to carry one of + `MCP_ALLOWED_AUDIENCES`. Pick whichever the provider can emit: + - `aud` (the default): the provider adds a dedicated audience to the token — an audience + mapper (Keycloak, dev setup: `docs-mcp`), an API identifier, or this server's + `MCP_RESOURCE_URL` if the provider honours RFC 8707 resource indicators (MCP clients send + it as the `resource` parameter). If adding the audience needs an extra scope, list it in + `MCP_EXTRA_SCOPES` so it's advertised to clients. + - the origin client, for providers that cannot add a custom audience: + `MCP_AUDIENCE_CLAIM=client_id` (RFC 9068) or `azp`, and + `MCP_ALLOWED_AUDIENCES=`. +4. **The Docs scopes.** `docs:documents:search`, `docs:documents:read` and + `docs:documents:create` must be requestable and end up in the token's `scope` claim: each tool + checks its own scope, and Django requires at least one of `OIDC_RS_SCOPES`. +5. **An OAuth client for MCP clients.** A public client (no secret, PKCE) whose `client_id` is + either pre-registered and pinned in each MCP client (what the dev setup does), or obtained + through Dynamic Client Registration if the provider supports it. Its redirect URIs must cover + the MCP clients' local callbacks. +6. **Token introspection for Django.** A confidential client Django authenticates as + (`OIDC_RS_CLIENT_ID`/`OIDC_RS_CLIENT_SECRET`) at the provider's introspection endpoint + (`OIDC_OP_INTROSPECTION_ENDPOINT`). The introspection response must contain `active`, `iss`, + `scope` and the claim named by `OIDC_RS_AUDIENCE_CLAIM` (default `client_id`), whose value + must be in `OIDC_RS_ALLOWED_AUDIENCES` — typically the MCP client's `client_id`. Providers + returning signed/encrypted JWT introspection responses (RFC 9701) are supported through + `OIDC_RS_BACKEND_CLASS` and the `OIDC_RS_*` key settings, see + [`resource_server.md`](./resource_server.md). +7. **Stable `sub`.** Django resolves the Docs user by the token's `sub`, so it must be the same + subject Docs users log in with through `OIDC_OP_*`. + +## Configuration + +### docs-mcp (`src/frontend/servers/mcp`) + +| Variable | Default | Purpose | +| --- | --- | --- | +| `MCP_HOST` / `MCP_PORT` | `0.0.0.0` / `4455` | bind address for the Express server | +| `MCP_RESOURCE_URL` | required | this server's `/mcp` URL, used as the OAuth "resource" and in PRM metadata | +| `MCP_OIDC_ISSUER` | required | issuer as seen by clients/tokens (`iss` claim check), e.g. `http://localhost:8083/realms/impress` | +| `MCP_OIDC_JWKS_URL` / `MCP_OIDC_DISCOVERY_URL` | required | JWKS and discovery endpoints, as reachable from this server (`http://nginx:8083/...` in Docker) | +| `MCP_AUDIENCE_CLAIM` | `aud` | token claim that must identify this server: `aud`, or e.g. `client_id` / `azp` | +| `MCP_ALLOWED_AUDIENCES` | required | comma-separated values accepted for `MCP_AUDIENCE_CLAIM` (at least one; the check cannot be disabled) | +| `MCP_EXTRA_SCOPES` | empty | comma-separated scopes advertised in the protected resource metadata on top of the `docs:documents:*` ones | +| `DOCS_API_URL` | required | Django's base URL, where the caller's token is forwarded | + +With `MCP_AUDIENCE_CLAIM=aud` the token's `aud` (a string or an array) must contain one of the +allowed values; with any other claim, that claim's value must be one of them. + +This server holds no credentials: it only reads the provider's public JWKS to verify token +signatures and forwards the caller's token to Django. `MCP_OIDC_ISSUER` is split from the +`*_URL` variables because this server may reach the provider through a different hostname than +the one in the token's `iss` claim — in Docker, the internal `nginx` alias instead of the +externally-visible `localhost:8083` — the same split Django's own OIDC settings use (`OIDC_OP_URL` vs `OIDC_OP_JWKS_ENDPOINT`). -## Keycloak configuration +These variables are `MCP_`-prefixed rather than bare `OIDC_*` on purpose: Django already reads a +large `OIDC_OP_*` / `OIDC_RS_*` family, and look-alike names with a different meaning (e.g. +`OIDC_AUDIENCE_CLAIM` next to `OIDC_RS_AUDIENCE_CLAIM`) would be easy to mix up in a deployment +sharing one configuration. -All of it is declared in `docker/auth/realm.json` — no manual Admin Console steps required. -There is no token exchange, so there are only two clients: +### Django (`src/backend`) -1. **Client scopes**: `docs:documents:search`, `docs:documents:read`, `docs:documents:create` - (plain) and `docs-mcp` (one `oidc-audience-mapper` adding the audience `docs-mcp`). All four - are *optional* scopes on `docs-mcp-client` only, so the audience is added only when the - client requests it. -2. **`docs-mcp-client`** (public): Authorization Code + PKCE (`S256`), no secret, no implicit - flow, no direct grants. This is the client the MCP client authenticates the user as; the - token it obtains is forwarded all the way to Django. -3. **`docs-api`** (confidential): the client Django authenticates as when calling Keycloak's - token introspection endpoint (`OIDC_RS_CLIENT_ID`/`OIDC_RS_CLIENT_SECRET`). No mappers, no - special attributes. +The MCP endpoints reuse the resource server settings, see +[`resource_server.md`](./resource_server.md) and [`env.md`](./env.md): -Django trusts the forwarded token only after `ResourceServerAuthentication` introspects it at -Keycloak on every call, checking it's `active`, its `iss` matches `OIDC_OP_URL`, it carries an -`OIDC_RS_SCOPES` scope, and its origin client (`OIDC_RS_AUDIENCE_CLAIM`, default `client_id`) is -in `OIDC_RS_ALLOWED_AUDIENCES` (`docs-mcp-client`). A token minted for another client, or -missing the MCP scopes, cannot reach these endpoints. +| Setting | Purpose for the MCP endpoints | +| --- | --- | +| `OIDC_OP_URL` | expected issuer of the introspected token | +| `OIDC_OP_INTROSPECTION_ENDPOINT` | where the forwarded token is introspected | +| `OIDC_RS_CLIENT_ID` / `OIDC_RS_CLIENT_SECRET` | confidential client Django introspects as | +| `OIDC_RS_SCOPES` | the token must carry at least one of them (`docs:documents:search,docs:documents:read,docs:documents:create`) | +| `OIDC_RS_AUDIENCE_CLAIM` | introspection claim naming the token's origin client (default `client_id`) | +| `OIDC_RS_ALLOWED_AUDIENCES` | accepted values for that claim (the MCP client's `client_id`) | `OIDC_RS_*` in `impress/settings.py` is process-wide, shared with the (currently disabled) `external_api` feature — there is only one resource-server identity per Django process. `OIDC_RS_ALLOWED_AUDIENCES` is a list, so a deployment running both would add each integration's -client_id alongside `docs-mcp-client`, no code change needed. +client_id alongside the MCP client's, no code change needed. The MCP endpoints are mounted +regardless of `OIDC_RESOURCE_SERVER_ENABLED`, which only gates `external_api`. + +## Example: the development Keycloak realm + +The development stack ships a Keycloak realm with everything above declared in +`docker/auth/realm.json` — no manual Admin Console steps required. It maps onto the +requirements as follows: + +1. **Client scopes**: `docs:documents:search`, `docs:documents:read`, `docs:documents:create` + (plain) and `docs-mcp` (one `oidc-audience-mapper` adding the audience `docs-mcp`). All four + are *optional* scopes on `docs-mcp-client` only, so the audience is added only when the + client requests it — hence `MCP_AUDIENCE_CLAIM=aud`, `MCP_ALLOWED_AUDIENCES=docs-mcp` and + `MCP_EXTRA_SCOPES=docs-mcp` in `env.d/development/mcp`. +2. **`docs-mcp-client`** (public): Authorization Code + PKCE (`S256`), no secret, no implicit + flow, no direct grants. This is the client the MCP client authenticates the user as; the + token it obtains is forwarded all the way to Django, where + `OIDC_RS_ALLOWED_AUDIENCES=docs-mcp-client` allow-lists it. +3. **`docs-api`** (confidential): the client Django authenticates as when calling Keycloak's + token introspection endpoint (`OIDC_RS_CLIENT_ID=docs-api`/`OIDC_RS_CLIENT_SECRET`). No + mappers, no special attributes. + +The audience mapper is a Keycloak feature. With a provider that can't add a custom audience, +drop the `docs-mcp` scope and allow-list the MCP client on the `docs-mcp` side as well, e.g. +`MCP_AUDIENCE_CLAIM=client_id` (or `azp`: Keycloak user access tokens carry `azp`, not +`client_id`) and `MCP_ALLOWED_AUDIENCES=docs-mcp-client`. If you edit `realm.json` and want Keycloak to run exactly what the file says, drop and recreate it (imports are additive on an existing realm, not a full reset): @@ -169,7 +252,7 @@ configured on this realm. ### Connecting other MCP clients -This realm does not expose Dynamic Client Registration (DCR) — `docker/auth/realm.json` only +The dev realm does not expose Dynamic Client Registration (DCR) — `docker/auth/realm.json` only declares `docs-mcp-client` statically. Clients that support pinning a static `client_id` (no DCR, no client secret needed since it's a public client) can still connect. A `make` target generates each client's config on first run and launches it: @@ -205,14 +288,17 @@ docker compose exec mcp-development yarn test ## Known limitations +- `docs-mcp` only accepts JWT access tokens (verified locally against the JWKS). Providers + issuing opaque access tokens would need token introspection in `docs-mcp` too, which is not + implemented; Django's side already works with them since it always introspects. - No refresh-token handling in the MCP client flow; long sessions would need one. -- No JWKS caching beyond `jose`'s in-memory default, no retry/backoff on Keycloak or Django - calls, no rate limiting beyond Django's existing `DocumentThrottle`. +- No JWKS caching beyond `jose`'s in-memory default, no retry/backoff on the OIDC provider or + Django calls, no rate limiting beyond Django's existing `DocumentThrottle`. - Stateless mode means every request pays the cost of a new `McpServer`/transport instance — not tuned for high throughput. -- No dynamic client registration: only pre-registered clients (`docs-mcp-client`) can connect. - A production deployment needs a considered DCR policy or a small fleet of statically - registered clients. +- No dynamic client registration in the dev realm: only pre-registered clients + (`docs-mcp-client`) can connect. A production deployment needs a considered DCR policy or a + small fleet of statically registered clients. - No structured logging/metrics/tracing beyond what Express/Django already provide by default. - The forwarded token is only audience-narrowed once (its origin `client_id` must be allow-listed). An Authorization Server supporting RFC 8693 token exchange could mint a diff --git a/env.d/development/mcp b/env.d/development/mcp index da9a623bf..46621faf0 100644 --- a/env.d/development/mcp +++ b/env.d/development/mcp @@ -5,15 +5,23 @@ MCP_HOST=0.0.0.0 MCP_PORT=4455 MCP_RESOURCE_URL=http://localhost:4455/mcp -# Keycloak: KEYCLOAK_ISSUER is the externally-visible issuer (used to validate the `iss` claim -# and in metadata); the *_URL vars are the network-reachable endpoints from inside Docker. -# This server only reads Keycloak's JWKS and discovery document — it holds no credentials and -# performs no token exchange; it forwards the caller's access token to Django as-is. -KEYCLOAK_ISSUER=http://localhost:8083/realms/impress -KEYCLOAK_JWKS_URL=http://nginx:8083/realms/impress/protocol/openid-connect/certs -KEYCLOAK_DISCOVERY_URL=http://nginx:8083/realms/impress/.well-known/openid-configuration +# OIDC provider (the dev Keycloak realm here; any provider issuing JWT access tokens works). +# MCP_OIDC_ISSUER is the externally-visible issuer (used to validate the `iss` claim and in +# metadata); the *_URL vars are the network-reachable endpoints from inside Docker. +# This server only reads the provider's JWKS and discovery document — it holds no credentials +# and performs no token exchange; it forwards the caller's access token to Django as-is. +MCP_OIDC_ISSUER=http://localhost:8083/realms/impress +MCP_OIDC_JWKS_URL=http://nginx:8083/realms/impress/protocol/openid-connect/certs +MCP_OIDC_DISCOVERY_URL=http://nginx:8083/realms/impress/.well-known/openid-configuration # Django is reached directly (not through nginx, which only proxies to Keycloak on :8083). DOCS_API_URL=http://app-dev:8000 -MCP_AUDIENCE=docs-mcp +# Tokens must carry aud=docs-mcp, which the dev realm only adds when the `docs-mcp` optional +# scope is requested (so it is advertised to clients too). For a provider that cannot add a +# custom audience, allow-list the MCP client instead, e.g.: +# MCP_AUDIENCE_CLAIM=client_id (RFC 9068 providers; Keycloak user tokens carry `azp`) +# MCP_ALLOWED_AUDIENCES=docs-mcp-client +MCP_AUDIENCE_CLAIM=aud +MCP_ALLOWED_AUDIENCES=docs-mcp +MCP_EXTRA_SCOPES=docs-mcp diff --git a/src/frontend/servers/mcp/__tests__/jwtVerifier.test.ts b/src/frontend/servers/mcp/__tests__/jwtVerifier.test.ts new file mode 100644 index 000000000..00998cc54 --- /dev/null +++ b/src/frontend/servers/mcp/__tests__/jwtVerifier.test.ts @@ -0,0 +1,216 @@ +/** + * Claim checks of `OidcJwtVerifier`, run against real `jose` verification with a locally + * generated key set (no network, no mocks), for both audience modes: the standard `aud` claim + * and a named claim such as `client_id`. Any rejection surfaces as `InvalidTokenError`, which + * the SDK's bearer middleware turns into a 401 (see server.test.ts). + */ + +import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; +import { + type JWTPayload, + type JWTVerifyGetKey, + SignJWT, + createLocalJWKSet, + exportJWK, + generateKeyPair, +} from 'jose'; +import { beforeAll, describe, expect, test, vi } from 'vitest'; + +import { + OidcJwtVerifier, + type OidcJwtVerifierOptions, +} from '@/auth/jwtVerifier.js'; + +const ISSUER = 'https://idp.example.test'; + +type PrivateKey = Awaited>['privateKey']; + +let privateKey: PrivateKey; +let jwks: JWTVerifyGetKey; + +beforeAll(async () => { + const keyPair = await generateKeyPair('RS256'); + privateKey = keyPair.privateKey; + const publicJwk = await exportJWK(keyPair.publicKey); + jwks = createLocalJWKSet({ + keys: [{ ...publicJwk, kid: 'test-key', alg: 'RS256' }], + }); + // Rejections are logged on purpose (see jwtVerifier.ts); keep the test output quiet. + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +async function sign( + claims: JWTPayload, + { + issuer = ISSUER, + key = privateKey, + }: { issuer?: string; key?: PrivateKey } = {}, +): Promise { + return new SignJWT({ sub: 'user-1', scope: 'docs:documents:read', ...claims }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer(issuer) + .setIssuedAt() + .setExpirationTime('5m') + .sign(key); +} + +function verifier( + options: Partial> = {}, +): OidcJwtVerifier { + return new OidcJwtVerifier({ + issuer: ISSUER, + jwks, + audienceClaim: 'aud', + allowedAudiences: ['docs-mcp'], + ...options, + }); +} + +describe('OidcJwtVerifier', () => { + describe('with the default `aud` claim', () => { + test('accepts a token whose aud string is allowed', async () => { + const token = await sign({ aud: 'docs-mcp', azp: 'docs-mcp-client' }); + + const authInfo = await verifier().verifyAccessToken(token); + + expect(authInfo).toMatchObject({ + token, + clientId: 'docs-mcp-client', + scopes: ['docs:documents:read'], + extra: { sub: 'user-1' }, + }); + expect(authInfo.expiresAt).toEqual(expect.any(Number)); + }); + + test('accepts a token whose aud array contains an allowed value', async () => { + const token = await sign({ aud: ['account', 'docs-mcp'] }); + + await expect(verifier().verifyAccessToken(token)).resolves.toMatchObject({ + extra: { sub: 'user-1' }, + }); + }); + + test('accepts any of several allowed audiences', async () => { + const token = await sign({ aud: 'http://localhost:4455/mcp' }); + + await expect( + verifier({ + allowedAudiences: ['docs-mcp', 'http://localhost:4455/mcp'], + }).verifyAccessToken(token), + ).resolves.toBeDefined(); + }); + + test('rejects a token with another audience', async () => { + const token = await sign({ aud: ['account', 'another-app'] }); + + await expect(verifier().verifyAccessToken(token)).rejects.toThrow( + InvalidTokenError, + ); + }); + + test('rejects a token without aud, even if client_id would match', async () => { + const token = await sign({ client_id: 'docs-mcp' }); + + await expect(verifier().verifyAccessToken(token)).rejects.toThrow( + InvalidTokenError, + ); + }); + }); + + describe('with a named claim such as `client_id`', () => { + const clientIdVerifier = () => + verifier({ + audienceClaim: 'client_id', + allowedAudiences: ['docs-mcp-client'], + }); + + test('accepts a token whose client_id is allowed, whatever its aud', async () => { + const token = await sign({ + aud: 'some-api', + client_id: 'docs-mcp-client', + }); + + await expect( + clientIdVerifier().verifyAccessToken(token), + ).resolves.toMatchObject({ clientId: 'docs-mcp-client' }); + }); + + test('accepts a token with no aud at all', async () => { + const token = await sign({ client_id: 'docs-mcp-client' }); + + await expect( + clientIdVerifier().verifyAccessToken(token), + ).resolves.toBeDefined(); + }); + + test('rejects a token whose client_id is not allowed', async () => { + const token = await sign({ + aud: 'docs-mcp', + client_id: 'another-client', + }); + + await expect(clientIdVerifier().verifyAccessToken(token)).rejects.toThrow( + 'Token client_id claim is not an allowed audience', + ); + }); + + test('rejects a token without the claim', async () => { + const token = await sign({ aud: 'docs-mcp', azp: 'docs-mcp-client' }); + + await expect(clientIdVerifier().verifyAccessToken(token)).rejects.toThrow( + 'Token has no client_id claim', + ); + }); + + test('rejects a token whose claim is not a string', async () => { + const token = await sign({ client_id: 42 }); + + await expect(clientIdVerifier().verifyAccessToken(token)).rejects.toThrow( + InvalidTokenError, + ); + }); + + test('supports other claim names, e.g. azp', async () => { + const token = await sign({ azp: 'docs-mcp-client' }); + + await expect( + verifier({ + audienceClaim: 'azp', + allowedAudiences: ['docs-mcp-client'], + }).verifyAccessToken(token), + ).resolves.toMatchObject({ clientId: 'docs-mcp-client' }); + }); + }); + + test('rejects a token from another issuer', async () => { + const token = await sign( + { aud: 'docs-mcp' }, + { issuer: 'https://evil.example.test' }, + ); + + await expect(verifier().verifyAccessToken(token)).rejects.toThrow( + InvalidTokenError, + ); + }); + + test('rejects a token signed with an unknown key', async () => { + const { privateKey: otherKey } = await generateKeyPair('RS256'); + const token = await sign({ aud: 'docs-mcp' }, { key: otherKey }); + + await expect(verifier().verifyAccessToken(token)).rejects.toThrow( + InvalidTokenError, + ); + }); + + test('rejects a token without a subject', async () => { + const token = await sign({ aud: 'docs-mcp', sub: undefined }); + + await expect(verifier().verifyAccessToken(token)).rejects.toThrow( + 'Token has no subject (sub) claim', + ); + }); + + test('refuses to be built without any allowed audience', () => { + expect(() => verifier({ allowedAudiences: [] })).toThrow(); + }); +}); diff --git a/src/frontend/servers/mcp/__tests__/server.test.ts b/src/frontend/servers/mcp/__tests__/server.test.ts index d1861ffac..70001a069 100644 --- a/src/frontend/servers/mcp/__tests__/server.test.ts +++ b/src/frontend/servers/mcp/__tests__/server.test.ts @@ -16,6 +16,7 @@ import { AddressInfo } from 'node:net'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import axios from 'axios'; +import request from 'supertest'; import { afterAll, afterEach, @@ -30,13 +31,15 @@ vi.mock('@/env.js', () => ({ MCP_HOST: '127.0.0.1', MCP_PORT: 0, MCP_RESOURCE_URL: 'http://localhost:4455/mcp', - KEYCLOAK_ISSUER: 'http://localhost/realms/impress', - KEYCLOAK_JWKS_URL: + MCP_OIDC_ISSUER: 'http://localhost/realms/impress', + MCP_OIDC_JWKS_URL: 'http://localhost/realms/impress/protocol/openid-connect/certs', - KEYCLOAK_DISCOVERY_URL: + MCP_OIDC_DISCOVERY_URL: 'http://localhost/realms/impress/.well-known/openid-configuration', DOCS_API_URL: 'http://docs-api.test', - MCP_AUDIENCE: 'docs-mcp', + MCP_AUDIENCE_CLAIM: 'aud', + MCP_ALLOWED_AUDIENCES: ['docs-mcp'], + MCP_EXTRA_SCOPES: ['docs-mcp'], })); vi.mock('axios'); @@ -51,14 +54,26 @@ type FakeTokenClaims = { let verifiedClaims: FakeTokenClaims | null = null; +// Stands in for jose's signature/issuer checks; the audience check is emulated so the +// verifier's `audience` option is exercised end to end (see jwtVerifier.test.ts for the real +// jose-backed claim checks). vi.mock('jose', () => ({ createRemoteJWKSet: vi.fn(() => ({})), - jwtVerify: vi.fn(async (token: string) => { - if (!verifiedClaims || token !== 'valid-token') { - throw new Error('invalid token'); - } - return { payload: verifiedClaims }; - }), + jwtVerify: vi.fn( + async ( + token: string, + _jwks: unknown, + options?: { audience?: string[] }, + ) => { + if (!verifiedClaims || token !== 'valid-token') { + throw new Error('invalid token'); + } + if (options?.audience && !options.audience.includes(verifiedClaims.aud)) { + throw new Error('unexpected "aud" claim value'); + } + return { payload: verifiedClaims }; + }, + ), })); import { createApp } from '@/server.js'; @@ -114,11 +129,51 @@ describe('docs-mcp server', () => { await expect(connectClient(url)).rejects.toThrow(); }); - test('a token with the wrong audience is rejected', async () => { - verifiedClaims = null; // jwtVerify mock throws for anything but 'valid-token' with claims set + test('an invalid token is rejected with 401', async () => { + const response = await request(server) + .post('/mcp') + .set('Authorization', 'Bearer not-a-valid-token') + .send({}); + + expect(response.status).toBe(401); + expect(response.headers['www-authenticate']).toContain('invalid_token'); + }); + + test('a token with the wrong audience is rejected with 401', async () => { + verifiedClaims = { + sub: 'user-1', + aud: 'another-app', + scope: 'docs:documents:search', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + const response = await request(server) + .post('/mcp') + .set('Authorization', 'Bearer valid-token') + .send({}); + + expect(response.status).toBe(401); await expect(connectClient(url, 'valid-token')).rejects.toThrow(); }); + test('the protected resource metadata advertises the tool and extra scopes', async () => { + const response = await request(server).get( + '/.well-known/oauth-protected-resource/mcp', + ); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + resource: 'http://localhost:4455/mcp', + authorization_servers: ['http://localhost/realms/impress'], + scopes_supported: [ + 'docs:documents:search', + 'docs:documents:read', + 'docs:documents:create', + 'docs-mcp', + ], + }); + }); + test('a tool call without its required scope is rejected', async () => { verifiedClaims = { sub: 'user-1', diff --git a/src/frontend/servers/mcp/src/auth/jwtVerifier.ts b/src/frontend/servers/mcp/src/auth/jwtVerifier.ts index 5a59a610d..337ad28b3 100644 --- a/src/frontend/servers/mcp/src/auth/jwtVerifier.ts +++ b/src/frontend/servers/mcp/src/auth/jwtVerifier.ts @@ -1,14 +1,16 @@ /** - * Validates Keycloak-issued MCP access tokens. + * Validates MCP access tokens issued by the configured OIDC provider. * * This implements the MCP SDK's `OAuthTokenVerifier` interface: given a raw bearer token, it * must either return `AuthInfo` (signature, issuer, expiry, audience and subject all valid) or * throw, in which case the SDK's `requireBearerAuth` middleware turns that into an HTTP 401. * - * We validate against Keycloak's realm JWKS (fetched via OIDC discovery) rather than trusting - * an unsigned token, and we require the `docs-mcp` audience explicitly: Keycloak only adds it - * when the `docs-mcp` optional client scope was requested (see docker/auth/realm.json), so its - * presence proves the client asked for an MCP-scoped token, not just any Keycloak token. + * Tokens must be signed JWTs: we validate them against the provider's JWKS rather than trusting + * an unsigned token, and we require the configured audience claim explicitly (see + * `MCP_AUDIENCE_CLAIM` in `env.ts`), so that a token minted for another application of the same + * provider is rejected here, before it is ever forwarded to Django. With the dev Keycloak realm + * that is `aud=docs-mcp`, which Keycloak only adds when the `docs-mcp` optional client scope + * was requested (see docker/auth/realm.json). * * Any error we don't recognize as InvalidTokenError gets swallowed by the SDK's bearerAuth * middleware into a generic, unlogged 500 — so we log (never the token itself) before @@ -18,19 +20,45 @@ import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; import type { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js'; import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; -import { type JWTPayload, createRemoteJWKSet, jwtVerify } from 'jose'; +import { type JWTPayload, type JWTVerifyGetKey, jwtVerify } from 'jose'; -import { KEYCLOAK_ISSUER, KEYCLOAK_JWKS_URL, MCP_AUDIENCE } from '@/env.js'; +export type OidcJwtVerifierOptions = { + /** Expected `iss` claim. */ + issuer: string; + /** Key resolver for signature checks, e.g. `createRemoteJWKSet(jwksUrl)`. */ + jwks: JWTVerifyGetKey; + /** Claim identifying this server: `aud` (default), `client_id`, `azp`, ... */ + audienceClaim: string; + /** Accepted values for `audienceClaim`; the token must carry at least one of them. */ + allowedAudiences: readonly string[]; +}; -const jwks = createRemoteJWKSet(new URL(KEYCLOAK_JWKS_URL)); +function claimValues(value: unknown): string[] { + if (typeof value === 'string') { + return [value]; + } + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string'); + } + return []; +} + +export class OidcJwtVerifier implements OAuthTokenVerifier { + constructor(private readonly options: OidcJwtVerifierOptions) { + if (options.allowedAudiences.length === 0) { + throw new Error('OidcJwtVerifier requires at least one allowed audience'); + } + } -export class KeycloakJwtVerifier implements OAuthTokenVerifier { async verifyAccessToken(token: string): Promise { + const { issuer, jwks, audienceClaim, allowedAudiences } = this.options; + let payload: JWTPayload; try { ({ payload } = await jwtVerify(token, jwks, { - issuer: KEYCLOAK_ISSUER, - audience: MCP_AUDIENCE, + issuer, + // jose handles the standard `aud` claim itself, string or array alike. + audience: audienceClaim === 'aud' ? [...allowedAudiences] : undefined, })); } catch (error) { console.error('JWT verification failed:', error); @@ -39,6 +67,24 @@ export class KeycloakJwtVerifier implements OAuthTokenVerifier { ); } + if (audienceClaim !== 'aud') { + const values = claimValues(payload[audienceClaim]); + if (values.length === 0) { + console.error( + `JWT verification failed: missing ${audienceClaim} claim`, + ); + throw new InvalidTokenError(`Token has no ${audienceClaim} claim`); + } + if (!values.some((value) => allowedAudiences.includes(value))) { + console.error( + `JWT verification failed: unexpected ${audienceClaim} claim value`, + ); + throw new InvalidTokenError( + `Token ${audienceClaim} claim is not an allowed audience`, + ); + } + } + if (typeof payload.sub !== 'string') { throw new InvalidTokenError('Token has no subject (sub) claim'); } diff --git a/src/frontend/servers/mcp/src/docsApiClient.ts b/src/frontend/servers/mcp/src/docsApiClient.ts index d20b2e1f6..43784a293 100644 --- a/src/frontend/servers/mcp/src/docsApiClient.ts +++ b/src/frontend/servers/mcp/src/docsApiClient.ts @@ -1,7 +1,7 @@ /** * Thin client for the Docs API's MCP-facing endpoints (`core/mcp_api` on the Django side). * - * The MCP server is a plain OAuth resource server: it validates the caller's Keycloak access + * The MCP server is a plain OAuth resource server: it validates the caller's OIDC access * token (see `auth/jwtVerifier.ts`) and forwards that same token, unchanged, to Django. Django * introspects it (django-lasuite's `ResourceServerAuthentication`), checks the token's origin * client is allow-listed (`OIDC_RS_ALLOWED_AUDIENCES`), resolves the user by `sub`, and its diff --git a/src/frontend/servers/mcp/src/env.ts b/src/frontend/servers/mcp/src/env.ts index 9cd2ae457..f9f025aff 100644 --- a/src/frontend/servers/mcp/src/env.ts +++ b/src/frontend/servers/mcp/src/env.ts @@ -8,6 +8,11 @@ function required(name: string): string { return value; } +/** Parses a comma- and/or whitespace-separated list, dropping empty entries. */ +function list(value: string | undefined): string[] { + return (value ?? '').split(/[\s,]+/).filter(Boolean); +} + export const MCP_HOST = process.env.MCP_HOST || '0.0.0.0'; export const MCP_PORT = Number(process.env.MCP_PORT || 4455); @@ -15,20 +20,45 @@ export const MCP_PORT = Number(process.env.MCP_PORT || 4455); export const MCP_RESOURCE_URL = required('MCP_RESOURCE_URL'); /** - * Keycloak realm issuer as seen by end users/clients, e.g. http://localhost:8083/realms/impress. - * Used only to validate the `iss` claim and to advertise metadata; never called over the - * network directly (see KEYCLOAK_*_URL below for the network-reachable endpoints), since in - * Docker this server reaches Keycloak through an internal alias, not through localhost. + * OIDC provider (authorization server) the access tokens come from. Any provider issuing + * signed JWT access tokens works; Keycloak (docker/auth/realm.json) is only the dev example. + * + * MCP_OIDC_ISSUER is the issuer as seen by end users/clients, e.g. + * http://localhost:8083/realms/impress in dev. It is used only to validate the `iss` claim and + * is never called over the network directly: the *_URL variables below are the endpoints this + * server actually fetches, which may use a different (internal) hostname, e.g. in Docker. + * + * Deliberately MCP_-prefixed rather than bare OIDC_*: the backend already reads a large + * OIDC_OP_* / OIDC_RS_* family, and near-identical names with different meanings (e.g. + * OIDC_JWKS_URL vs OIDC_OP_JWKS_ENDPOINT) would be easy to mix up in a shared deployment config. */ -export const KEYCLOAK_ISSUER = required('KEYCLOAK_ISSUER'); -export const KEYCLOAK_JWKS_URL = required('KEYCLOAK_JWKS_URL'); -export const KEYCLOAK_DISCOVERY_URL = required('KEYCLOAK_DISCOVERY_URL'); +export const MCP_OIDC_ISSUER = required('MCP_OIDC_ISSUER'); +export const MCP_OIDC_JWKS_URL = required('MCP_OIDC_JWKS_URL'); +export const MCP_OIDC_DISCOVERY_URL = required('MCP_OIDC_DISCOVERY_URL'); export const DOCS_API_URL = required('DOCS_API_URL'); /** - * Audience this server itself must find on incoming MCP access tokens. Keycloak only adds it - * when the `docs-mcp` optional client scope was requested (see docker/auth/realm.json), so its - * presence proves the client asked for an MCP-scoped token, not just any Keycloak token. + * Which access-token claim must identify this server, and which values are accepted — the + * counterpart of the backend's OIDC_RS_AUDIENCE_CLAIM / OIDC_RS_ALLOWED_AUDIENCES. + * + * - `aud` (default, RFC 9068): the token must be issued *for* this server, e.g. + * MCP_ALLOWED_AUDIENCES=docs-mcp with Keycloak's audience mapper, or this server's + * MCP_RESOURCE_URL with an authorization server honouring RFC 8707 resource indicators. + * - any other claim, typically `client_id` or `azp`: for providers that cannot add a custom + * audience, the token's origin client must be allow-listed instead. + * + * There is no way to disable the check: at least one allowed value is required. */ -export const MCP_AUDIENCE = process.env.MCP_AUDIENCE || 'docs-mcp'; +export const MCP_AUDIENCE_CLAIM = process.env.MCP_AUDIENCE_CLAIM || 'aud'; +export const MCP_ALLOWED_AUDIENCES = list(required('MCP_ALLOWED_AUDIENCES')); +if (MCP_ALLOWED_AUDIENCES.length === 0) { + throw new Error('MCP_ALLOWED_AUDIENCES must list at least one value'); +} + +/** + * Extra scopes advertised in the protected resource metadata, on top of the per-tool + * docs:documents:* scopes — e.g. a provider-specific scope that makes it add the audience + * (`docs-mcp` with the dev Keycloak realm). Empty by default. + */ +export const MCP_EXTRA_SCOPES = list(process.env.MCP_EXTRA_SCOPES); diff --git a/src/frontend/servers/mcp/src/prm.ts b/src/frontend/servers/mcp/src/prm.ts index 3e13a9f74..cfd1b5e6d 100644 --- a/src/frontend/servers/mcp/src/prm.ts +++ b/src/frontend/servers/mcp/src/prm.ts @@ -2,11 +2,11 @@ * OAuth Protected Resource Metadata (RFC 9728). * * This is how an MCP client discovers *which* authorization server to use and *which* scopes - * to request before it ever talks to Keycloak: it fetches + * to request before it ever talks to the OIDC provider: it fetches * `/.well-known/oauth-protected-resource` from this server, learns the issuer and supported - * scopes, then drives the Authorization Code + PKCE flow against Keycloak itself. + * scopes, then drives the Authorization Code + PKCE flow against the provider itself. * - * We reuse Keycloak's own OIDC discovery document as the `oauthMetadata` the SDK's helper + * We reuse the provider's own OIDC discovery document as the `oauthMetadata` the SDK's helper * expects, rather than hand-rolling one. */ @@ -15,22 +15,28 @@ import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth.js'; import axios from 'axios'; import type { Router } from 'express'; -import { KEYCLOAK_DISCOVERY_URL, MCP_RESOURCE_URL } from '@/env.js'; +import { + MCP_EXTRA_SCOPES, + MCP_OIDC_DISCOVERY_URL, + MCP_RESOURCE_URL, +} from '@/env.js'; + +/** Scopes the tools check (see src/mcp/tools/); Django requires them too (OIDC_RS_SCOPES). */ +const TOOL_SCOPES = [ + 'docs:documents:search', + 'docs:documents:read', + 'docs:documents:create', +]; export async function buildProtectedResourceMetadataRouter(): Promise { const { data: oauthMetadata } = await axios.get( - KEYCLOAK_DISCOVERY_URL, + MCP_OIDC_DISCOVERY_URL, ); return mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: new URL(MCP_RESOURCE_URL), resourceName: 'docs-mcp', - scopesSupported: [ - 'docs:documents:search', - 'docs:documents:read', - 'docs:documents:create', - 'docs-mcp', - ], + scopesSupported: [...new Set([...TOOL_SCOPES, ...MCP_EXTRA_SCOPES])], }); } diff --git a/src/frontend/servers/mcp/src/server.ts b/src/frontend/servers/mcp/src/server.ts index fe7088841..739651f72 100644 --- a/src/frontend/servers/mcp/src/server.ts +++ b/src/frontend/servers/mcp/src/server.ts @@ -2,9 +2,16 @@ import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middlew import { getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; +import { createRemoteJWKSet } from 'jose'; -import { KeycloakJwtVerifier } from '@/auth/jwtVerifier.js'; -import { MCP_RESOURCE_URL } from '@/env.js'; +import { OidcJwtVerifier } from '@/auth/jwtVerifier.js'; +import { + MCP_ALLOWED_AUDIENCES, + MCP_AUDIENCE_CLAIM, + MCP_OIDC_ISSUER, + MCP_OIDC_JWKS_URL, + MCP_RESOURCE_URL, +} from '@/env.js'; import { buildServer } from '@/mcp/buildServer.js'; import { buildProtectedResourceMetadataRouter } from '@/prm.js'; @@ -18,7 +25,12 @@ export async function createApp(): Promise { app.use(await buildProtectedResourceMetadataRouter()); - const verifier = new KeycloakJwtVerifier(); + const verifier = new OidcJwtVerifier({ + issuer: MCP_OIDC_ISSUER, + jwks: createRemoteJWKSet(new URL(MCP_OIDC_JWKS_URL)), + audienceClaim: MCP_AUDIENCE_CLAIM, + allowedAudiences: MCP_ALLOWED_AUDIENCES, + }); const mcpAuth = requireBearerAuth({ verifier, resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(