Files
Flash5/flash-extensions/flash-ext-mcp/docs/keycloak.md
T
Zakaria El Orche 891ef99b8e
CI / Build & Test (push) Failing after 4m51s
CI / Build & Test (pull_request) Canceled after 23s
refactor(core): make boot and middleware ordering deterministic
2026-08-12 16:42:49 +00:00

6.3 KiB

Keycloak cookbook

security.md covers the OAuth2 mechanics McpOidcIntegration implements against any flash-ext-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 → basicMappersAdd mapperBy configurationAudience.
  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.