Files
Flash5/flash-extensions/flash-ext-mcp/docs/security.md
T
Zakaria El Orche 9d39e24ccb refactor(ext-auth): generic sessions, shared annotation wiring, rename to flash-ext-auth-oidc
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.
2026-09-10 19:06:15 +00:00

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-auth-oidc being installed (ctx.find(OidcMiddleware.class)), resolved once at boot in McpExtension.routes():

Policy flash-ext-auth-oidc installed flash-ext-auth-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-auth-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-auth-oidc pulled in automatically unless they add it themselves. Within flash-ext-mcp itself, flash-ext-auth-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-auth-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:

  1. The MCP route is wrapped with flash-ext-auth-oidc's own OidcMiddleware.protect(resourceMetadataPath) — the same Bearer-token/JWKS validation path used everywhere else in Flash5, plus a resource_metadata challenge parameter (see below). No JWT parsing or JWKS handling is reimplemented here.
  2. An audience guard always runs after protect(...): it reads the validated claims from ClaimsHolder and rejects (403) any token whose aud claim does not include the resource identifier — RFC 8707 Resource Indicators / audience binding, enforced unconditionally, not opt-in. OidcMiddleware itself validates aud against its own clientId for 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.
  3. The resource identifier is the canonical URI of the MCP endpoint, resolved per request by OidcMiddleware#selfOrigin + rootPath — the same scheme/host resolution OidcExtension uses for its own redirect URIs: X-Forwarded-Host/X-Forwarded-Proto when the request came through a reverse proxy, otherwise {selfScheme()}://{Host header}. Behind a proxy the Host alone 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.
  4. The authorization server issuer is read from OidcMiddleware#issuer() unless McpConfig.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 (profilepreferred_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-auth-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-auth-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-auth-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.