AuthMiddleware.install(ctx, config, source) now owns the annotation processor and the flash.auth.policy key, so a second credential source gets annotation-driven authorization without copying the wiring. The key is public: an extension that contributes middleware can order itself around authentication. OidcSession becomes Session in auth-core, carrying claims, an expiry and an opaque attribute map. OpenID Connect keeps its access, id and refresh tokens in that map under its own keys, so renewal stays its business and core has no OAuth2 vocabulary in it. isAccessTokenExpired() becomes isExpired(), with the 30s eager-renewal window it always had and now a test for it. flash-ext-oidc is renamed flash-ext-auth-oidc, matching cache-core/cache-caffeine and data-core/data-hibernate.
108 lines
6.4 KiB
Markdown
108 lines
6.4 KiB
Markdown
# Keycloak cookbook
|
|
|
|
`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any
|
|
`flash-ext-auth-oidc`-compatible provider. This is the Keycloak-specific setup: the exact Admin
|
|
Console configuration for a working MCP OAuth2 flow with open Dynamic Client Registration
|
|
(DCR) — no pre-registered clients, any MCP client self-registers on first connect.
|
|
|
|
## 1. Allow Dynamic Client Registration
|
|
|
|
MCP clients (Claude Desktop, Claude.ai, MCP Inspector, others) don't share one static OAuth
|
|
client — each has its own `redirect_uri` and none know your realm in advance. They self-register
|
|
on first connect via `POST {issuer}/clients-registrations/openid-connect` (the
|
|
`registration_endpoint` from the AS metadata document, reached via the RFC 9728 Protected
|
|
Resource Metadata document `McpExtension` publishes).
|
|
|
|
**Clients → Client registration**: remove the **Trusted Hosts** policy — it rejects anonymous
|
|
registration from hosts not on an explicit allowlist (`403` / `"Host not trusted"`), which
|
|
doesn't scale to arbitrary future agents. This does not weaken end-user authentication — DCR
|
|
only grants an app a `client_id`; every user still authenticates against Keycloak's real login
|
|
screen regardless of which client asked. Lighter hygiene policies (**Max Clients Limit**,
|
|
**Consent Required**) can stay, they don't interfere.
|
|
|
|
## 2. RFC 8707 audience: mapper on `basic`, not a custom scope
|
|
|
|
`McpOidcIntegration` rejects (403) any token whose `aud` doesn't include the MCP endpoint's
|
|
canonical URL. Keycloak doesn't add this by default. The obvious fix — a custom client scope
|
|
with an Audience mapper, marked Default, added to Allowed Client Scopes — **does not work**:
|
|
clients created via the `openid-connect` DCR endpoint only ever get scopes they explicitly
|
|
request, and most MCP clients (including MCP Inspector) don't request anything beyond what a
|
|
server tells them to via `scopes_supported` (step 3). Default-scope auto-attachment, which is
|
|
how a normal manually-created client would pick up a custom Default scope, doesn't apply to
|
|
DCR-created clients at all.
|
|
|
|
`basic` is the one built-in scope Keycloak attaches to every client unconditionally, regardless
|
|
of what it registered with. Put the audience mapper there:
|
|
|
|
1. **Client scopes → `basic`** → **Mappers** → **Add mapper** → **By configuration** →
|
|
**Audience**.
|
|
2. **Included Custom Audience** = the exact value your server expects — check
|
|
`GET {parent-of-rootPath}/.well-known/oauth-protected-resource{rootPath}` on the running
|
|
server for the `resource` field it publishes (auto-derived from the request's
|
|
forwarded/`Host` headers — see `security.md`). Leave **Included Client Audience** empty (that
|
|
targets another Keycloak client, not a resource URL).
|
|
3. **Add to access token** = ON.
|
|
4. **Save.**
|
|
|
|
This is unconditional and works regardless of client cooperation — keep it even after step 3
|
|
below gets other claims flowing normally, since audience binding is a hard spec requirement
|
|
that shouldn't depend on a client bothering to request the right scope.
|
|
|
|
## 3. Other claims (username, email...): `scopes_supported` + Allowed Client Scopes
|
|
|
|
`OidcUser.username()`/`.email()`/`.name()` read `preferred_username`/`email`/`name` — normally
|
|
from the `profile`/`email` client scopes, which DCR clients don't get either, same root cause.
|
|
Unlike audience, this **is** fixable the "normal" way, because it doesn't need to survive a
|
|
completely uncooperative client:
|
|
|
|
`McpConfig.scopesSupported("openid", "profile", "email")` publishes those scopes in the PRM
|
|
document. MCP clients that read it (confirmed for MCP Inspector) echo them back in their DCR
|
|
registration request — `"scope": "openid profile email offline_access"` (`offline_access` is
|
|
Inspector's own addition, for refresh tokens). For that request to actually succeed, **Allowed
|
|
Client Scopes** needs, exactly:
|
|
|
|
- **`openid` listed explicitly.** The one genuinely non-obvious step: `openid` is not covered by
|
|
**Allow Default Scopes** (On by default) the way other realm-Default scopes are, even though
|
|
every OIDC request includes it. Until it's listed here, registration fails with a generic
|
|
`403 insufficient_scope` / `"Not permitted to use specified clientScope"` regardless of
|
|
whether everything else is configured correctly.
|
|
- **`offline_access` listed explicitly** — it's Optional, not Default, so `ALLOW_DEFAULT_SCOPES`
|
|
doesn't cover it either.
|
|
- **`profile`/`email` — do not list them here.** Mark them **Default** on the **Client scopes**
|
|
page (Assigned Type column) instead, and leave **Allow Default Scopes** = On. Adding an
|
|
already-Default scope to this list explicitly gets rejected on save
|
|
(`"Client scopes not allowed: [...]"`) — the list is for *additional* Optional scopes only.
|
|
|
|
With that, a real client's token comes back with `preferred_username`/`email` populated
|
|
normally.
|
|
|
|
### Fallback for anything else
|
|
|
|
For a claim not covered by `openid profile email` (a custom attribute, a role) — or for a client
|
|
that ignores `scopes_supported` entirely — add a **User Property** mapper to `basic` too
|
|
(Property `username` → Token Claim Name `preferred_username`, or whatever's needed), same as the
|
|
audience mapper in step 2. Unconditional, works regardless of client cooperation, costs one
|
|
mapper per claim, once, at the realm level — not per tool.
|
|
|
|
## Verifying without a full OAuth round-trip
|
|
|
|
**Clients → (any client) → Client scopes → Evaluate**: pick a user, run it — Default scopes
|
|
(including `basic`) apply automatically and won't appear in the "Select scope parameters"
|
|
picker, which only lists Optional ones — and check the **Generated Access Token** preview.
|
|
Confirms mappers work without a browser + real MCP client round-trip each time.
|
|
|
|
## If a real client still gets rejected
|
|
|
|
`McpOidcIntegration.audienceGuard` logs the actual mismatch at `WARN`:
|
|
|
|
```
|
|
[flash-ext-mcp] Rejecting token (RFC 8707): aud=<token's actual aud> does not include expected
|
|
resource identifier "<what this server expects>" — ...
|
|
```
|
|
|
|
`aud=null` → the `basic` mapper produced nothing (most common cause: **Included Custom
|
|
Audience** left blank — the mapper saves fine and silently does nothing without it). A non-null
|
|
`aud` that still doesn't match → compare byte-for-byte — the expected side is derived from the
|
|
request's own forwarded/`Host` headers, so scheme/host/trailing-slash mismatches show up here
|
|
directly, as does a proxy hop that drops `X-Forwarded-Host`.
|