mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-26 11:35:05 +02:00
♻️(mcp) make the docs-mcp server OIDC provider-agnostic
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.
This commit is contained in:
+143
-57
@@ -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=<the MCP client's client_id>`.
|
||||
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
|
||||
|
||||
+16
-8
@@ -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
|
||||
|
||||
@@ -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<ReturnType<typeof generateKeyPair>>['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<string> {
|
||||
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<Omit<OidcJwtVerifierOptions, 'jwks'>> = {},
|
||||
): 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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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<AuthInfo> {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Router> {
|
||||
const { data: oauthMetadata } = await axios.get<OAuthMetadata>(
|
||||
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])],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<express.Express> {
|
||||
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user