Skip to main content
Glama
atulram

Keycloak Realm Inspector

by atulram
README.md
# Keycloak Realm Inspector

An MCP server that is purely an OAuth 2.0 protected resource, for a KeyConf 2026
talk on Client ID Metadata Documents
(`draft-ietf-oauth-client-id-metadata-document-02`).

It issues nothing — no `/authorize`, no `/token`, no client secret. Keycloak
issues tokens; this server validates them against Keycloak's JWKS, serves RFC
9728 discovery, and exposes three tools that make CIMD visible from inside the
resource server.

## Three parties, deliberately kept apart

| | |
|---|---|
| **Keycloak** | the authorization server. `--features=cimd`, `KC_HTTP_RELATIVE_PATH=/auth`, port 8080 |
| **The MCP client** | publishes its metadata document at a URL it controls. That URL *is* its `client_id`. Served on port 9000 |
| **This server** | the protected resource. Port 9001 |

**This server does not host, read or fetch the client's metadata document.**
There is no `/client-metadata.json` route and no local copy. Keycloak fetches
that URL over the network during `/authorize`. In this codebase the URL only
ever appears as an opaque string arriving in the `azp` claim — which is the
entire point of the demo, and why the document is served by a separate process
on a separate port.

## Run

Keycloak is assumed to be up, with the realm created and the CIMD client policy
already applied. The client, its metadata document and the token helper live
alongside this repo in `../cimd-demo/auth-server/`.

```bash
# terminal 1 — the client's document. Its own party, its own port.
cd ../cimd-demo/auth-server && python -m http.server 9000

# terminal 2 — the resource server
pip install -r requirements.txt
python server.py                     # http://localhost:9001

# terminal 3 — get a token, then point an MCP client at :9001/mcp
cd ../cimd-demo/auth-server && python get-token.py
```

## The flow

1. Client calls `/mcp` with no token, gets 401 with
   `WWW-Authenticate: Bearer ..., resource_metadata="…"`.
2. Client follows that to `/.well-known/oauth-protected-resource/mcp` and learns
   which authorization server issues valid tokens.
3. Client authenticates against Keycloak directly, using its metadata document
   URL as `client_id`. Keycloak fetches that URL and materialises the client.
4. Client retries `/mcp` with the bearer token.

RFC 9728 §3.1 puts the well-known segment *between host and resource path*, so
the discovery document lives at `/.well-known/oauth-protected-resource/mcp`. The
bare path is a 404.

## Tools

- **`whoami()`** — `sub`, `preferred_username`, `azp`, `scope`, `exp`, `iss`
  from the caller's token, plus `registered_via_cimd`. Reads the token only; no
  Keycloak call, so it works even if the admin credentials are wrong.
- **`list_clients(only_cimd=False)`** — realm clients, URL-shaped `client_id`s
  sorted to the top so the calling client lands on the first line.
- **`get_client_metadata(client_id)`** — Keycloak's own representation of a
  client. This is Keycloak's *derived view*, not the published document.

CIMD is detected by `client_id` starting with `http://` or `https://`. A
URL-shaped `client_id` is the tell.

`get_client_metadata` strips `secret` and `registrationAccessToken` before
returning. Both are live bearer credentials, and this output goes on a projector
and into a recording.

## Two known gaps

**RFC 8707 resource indicators.** `audience=` is present but commented out on
`JWTVerifier`. Audience-restricting the token would require the client to send
`resource` on `/authorize`, and Keycloak 26.7's CIMD path does not honour it yet
(keycloak#45106, keycloak#45284). So the token carries Keycloak's usual
`account` audience and this server cannot audience-restrict. Visible on purpose.

**No scope enforcement.** `required_scopes` is unset, so any signature-valid,
unexpired token from the realm is accepted — including one from an `admin-cli`
password grant. That is useful on stage: run `whoami()` with an admin token
(`azp` is an opaque string) and again with the CIMD token (`azp` is a URL), same
tool, same server, the difference in one field. It also means you must be
certain which token you are holding.

## Configuration

All `os.getenv` with localhost defaults.

| Variable | Default |
|---|---|
| `KEYCLOAK_BASE_URL` | `http://localhost:8080/auth` |
| `KEYCLOAK_REALM` | `cimd-demo` |
| `KEYCLOAK_ISSUER` | `{base}/realms/{realm}` |
| `KEYCLOAK_JWKS_URI` | `{issuer}/protocol/openid-connect/certs` |
| `MCP_BASE_URL` | `http://localhost:9001` |
| `PORT` | `9001` |
| `KEYCLOAK_ADMIN` | `admin` |
| `KEYCLOAK_ADMIN_PASSWORD` | `admin` |

`KEYCLOAK_ISSUER` and `KEYCLOAK_JWKS_URI` are separately configurable rather
than derived from each other: in Kubernetes the issuer is the public URL, so
`iss` matches what clients see, while the JWKS fetch should go to the in-cluster
Service.

`fastmcp` is pinned exactly. The auth surface moves — `resource_server_url`
became `base_url`, `AccessToken.claims` arrived in 2.11.3 — and
`allowed_client_redirect_uris` is not a `RemoteAuthProvider` parameter in any
release, despite what the docs show. It belongs to `OAuthProxy`, which this
server does not use.