10 KiB
Security
Provider-specific setup steps (not generic OAuth2 mechanics) live in separate cookbooks —
keycloak.md for Keycloak: enabling Dynamic Client Registration, why the RFC 8707
audience mapper needs to go on the built-in basic scope instead of a custom one, and the exact
Allowed Client Scopes configuration scopes_supported needs to actually work.
McpSecurity
McpConfig.security(...) controls how the MCP endpoint reacts to flash-ext-oidc being
installed (ctx.find(OidcMiddleware.class)), resolved once at boot in McpExtension.routes():
| Policy | flash-ext-oidc installed |
flash-ext-oidc absent |
|---|---|---|
REQUIRED |
protected | boot fails (IllegalStateException) |
AUTO (default) |
protected | runs unprotected, logs a warning |
NONE |
never protected, even if oidc is installed elsewhere in the app | runs unprotected |
Use REQUIRED for anything you intend to run in production reachable over the network — it
turns "someone forgot to wire up OAuth2" into a startup crash instead of a silently open
endpoint. AUTO is meant for local development, where spinning up a real identity provider is
friction you don't want yet.
Why flash-ext-oidc is an optional Maven dependency, concretely
Maven's <optional>true</optional> only affects transitive propagation: consumers of
flash-ext-mcp don't get flash-ext-oidc pulled in automatically unless they add it themselves.
Within flash-ext-mcp itself, flash-ext-oidc's classes are on the compile/test classpath as
normal — this extension can (and does) reference OidcMiddleware/ClaimsHolder directly in
source.
That reference is isolated in its own class, McpOidcIntegration, invoked only from inside a
catch (NoClassDefFoundError) block. A bare class-literal like OidcMiddleware.class (which
ctx.find(OidcMiddleware.class) needs) forces the JVM to resolve that type the moment it's
evaluated — if flash-ext-oidc is not on the runtime classpath at all (a genuinely
MCP-only install, no OAuth2 anywhere in the app), the first such reference throws
NoClassDefFoundError. Keeping that reference inside a separate, lazily-loaded class means
McpExtension itself loads and works fine standalone; only the attempt to actually use OIDC
fails, and only when there's something to fail. This mirrors OidcExtension's own lazy bridge to
flash-ext-openapi — same technique, same reason.
OAuth2 resolution details — zero-config by default
When oidc is available and security() != NONE, McpOidcIntegration (an isolated,
lazily-loaded bridge — see its javadoc) derives everything an MCP OAuth2 resource server needs
straight from the installed OidcMiddleware, with no additional McpConfig calls required:
- The MCP route is wrapped with
flash-ext-oidc's ownOidcMiddleware.protect(resourceMetadataPath)— the same Bearer-token/JWKS validation path used everywhere else in Flash5, plus aresource_metadatachallenge parameter (see below). No JWT parsing or JWKS handling is reimplemented here. - An audience guard always runs after
protect(...): it reads the validated claims fromClaimsHolderand rejects (403) any token whoseaudclaim does not include the resource identifier — RFC 8707 Resource Indicators / audience binding, enforced unconditionally, not opt-in.OidcMiddlewareitself validatesaudagainst its ownclientIdfor ID tokens, but deliberately does not enforce audience on access tokens (it varies by provider) — the MCP extension adds that check on top, scoped to its own resource identifier. - The resource identifier is the canonical URI of the MCP endpoint, resolved per request by
OidcMiddleware#selfOrigin+rootPath— the same scheme/host resolutionOidcExtensionuses for its own redirect URIs:X-Forwarded-Host/X-Forwarded-Protowhen the request came through a reverse proxy, otherwise{selfScheme()}://{Host header}. Behind a proxy theHostalone is the upstream address the proxy dialled, which would publish a resource identifier no client can reach.McpConfig.resourceIdentifier(...)still overrides it outright for a proxy that forwards neither header. - The authorization server issuer is read from
OidcMiddleware#issuer()unlessMcpConfig.authorizationServerIssuer(...)overrides it.
RFC 9728 Protected Resource Metadata
Whenever the endpoint ends up protected, flash-ext-mcp publishes a Protected Resource Metadata
document at /.well-known/oauth-protected-resource{rootPath} — no explicit resourceIdentifier/
authorizationServerIssuer configuration required, both are auto-derived as described above:
{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/realms/myrealm"] }
resource is computed per request from the incoming request's forwarded/Host headers (see
above), so the document is correct without hardcoding the server's own public URL.
scopes_supported
Optional per RFC 9728, omitted from the document entirely unless set via
McpConfig.scopesSupported("openid", "profile", "email"):
{ "resource": "...", "authorization_servers": ["..."], "scopes_supported": ["openid", "profile", "email"] }
This is pure advertisement — token validation doesn't change based on it — but it matters in
practice: a client that ignores it and requests no scope at all (many do — see keycloak.md)
only gets back whatever the authorization server treats as always-included regardless of
request, which for Keycloak is just its built-in basic scope. A client that does read
scopes_supported and echoes it back in its authorization/token requests gets a token with the
claims those scopes actually provide (profile → preferred_username/name, etc.), without
needing every one of those claims hand-mapped onto basic. Set it to whatever scopes your
McpTools actually read off ClaimsHolder/OidcUser — there's no way to auto-derive this list,
it depends entirely on what your tools do with the claims.
WWW-Authenticate: resource_metadata (RFC 9728 §5.1)
The MCP Authorization spec requires a 401 to carry resource_metadata in
WWW-Authenticate, pointing at the Protected Resource Metadata document above — this is how a
spec-compliant client discovers the authorization server without out-of-band configuration.
OidcMiddleware.protect(String resourceMetadataPath) (an overload added specifically for this)
builds that challenge automatically:
WWW-Authenticate: Bearer realm="...", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
The plain OidcMiddleware.protect() (no argument), used by every other Flash5 app, is
unaffected — this parameter is additive and MCP-specific.
Per-tool @RolesAllowed/@ScopesAllowed
McpTool subclasses can carry flash-ext-oidc's @RolesAllowed/@ScopesAllowed:
@Tool(name = "delete_route", description = "Delete a route")
@RolesAllowed("admin")
public class DeleteRouteTool extends McpTool {
@Override public ToolResponse call(ToolArguments args) { ... }
}
This does not reuse flash-ext-oidc's per-route middleware mechanism (ctx.addAnnotationProcessor,
the thing that makes these annotations work on a RequestHandler) — it can't: every tool shares
one HTTP route (POST {rootPath}), already wrapped by whatever McpSecurity resolved above, so
there is no per-tool route to attach a different middleware chain to. Instead,
McpOidcIntegration.compileToolPolicy reads the annotations once at boot (McpRegistry.scan)
and compiles them into a closure (McpAuthPolicy) that McpDispatcher runs after the
route-wide auth has already succeeded and before invoking the specific tool named in the
tools/call request — narrowing what's already-authenticated, not replacing it. A denial is a
normal isError: true tool result (see ToolResponse.error), not an HTTP-level rejection — the
model sees why, the same as any other tool failure.
Roles are read via OidcUser#hasRole against McpConfig.rolesClaimPath(...) (default
"realm_access.roles", matching OidcConfig's own default — set this explicitly if the two
diverge; there's no way to read OidcConfig's actual configured value from here). Scopes use
OidcUser#hasScope's built-in default claim paths (scope/scp), no extra config needed.
@ScopesAllowed(match = ScopesAllowed.Match.ANY) and multi-role @RolesAllowed({"admin", "editor"}) (OR semantics) both work exactly as they do on a RequestHandler.
@Authenticated alone has no effect and fails boot. Once oidc is active for a server, every
tool call is already authenticated — there's no per-tool public/authenticated split the way
there is for HTTP routes, so a bare @Authenticated on a tool can't mean anything and would
silently do nothing if allowed to compile. Boot fails instead, with a message pointing at
@RolesAllowed/@ScopesAllowed as the actual narrowing mechanism.
Annotating a tool without active OAuth2 also fails boot, not silently at request time: if
@RolesAllowed/@ScopesAllowed/@Authenticated shows up on a tool while McpSecurity resolved
to unprotected (NONE, or AUTO with no oidc installed), that's very likely a forgotten
OidcExtension install or a McpSecurity.NONE left over from local dev — IllegalStateException
at app.start().
The HttpException safety net
flash-ext-oidc's middleware throws HttpException.unauthorized()/forbidden() on auth
failure. Flash5's core does not special-case HttpException in the default exception
handler — the out-of-the-box AbstractRouter default always returns a generic 500, regardless
of the thrown exception's embedded status code; only an app that explicitly calls
FlashApp#onException(...) (or installs something that does) gets HttpException.status()
honored.
To keep the MCP endpoint correct regardless of what the rest of the app configures,
McpTransportGuards.httpExceptionGuard() wraps the whole route and translates HttpException
into the right HTTP status itself, rather than letting it fall through to the app's (possibly
unconfigured) global handler. This is scoped entirely to the MCP route — it does not touch or
override the app's onException for any other route.