♻️(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:
Anthony LC
2026-09-25 12:07:59 +02:00
parent 72459f25de
commit de94df3fe7
9 changed files with 573 additions and 114 deletions
+143 -57
View File
@@ -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
View File
@@ -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
+41 -11
View File
@@ -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);
+17 -11
View File
@@ -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])],
});
}
+15 -3
View File
@@ -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(