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.
171 lines
10 KiB
Markdown
171 lines
10 KiB
Markdown
# Security
|
|
|
|
Provider-specific setup steps (not generic OAuth2 mechanics) live in separate cookbooks —
|
|
[`keycloak.md`](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:
|
|
|
|
```json
|
|
{ "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")`:
|
|
|
|
```json
|
|
{ "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
|
|
`McpTool`s 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`:
|
|
|
|
```java
|
|
@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.
|