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.
6.4 KiB
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:
- Client scopes →
basic→ Mappers → Add mapper → By configuration → Audience. - 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 theresourcefield it publishes (auto-derived from the request's forwarded/Hostheaders — seesecurity.md). Leave Included Client Audience empty (that targets another Keycloak client, not a resource URL). - Add to access token = ON.
- 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:
openidlisted explicitly. The one genuinely non-obvious step:openidis 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 generic403 insufficient_scope/"Not permitted to use specified clientScope"regardless of whether everything else is configured correctly.offline_accesslisted explicitly — it's Optional, not Default, soALLOW_DEFAULT_SCOPESdoesn'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.