refactor(ext-oidc): replace auth modules with security extensions
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
`flash-ext-mcp` turns a Flash5 app into an [MCP](https://modelcontextprotocol.io) (Model Context
|
||||
Protocol) server: JSON-RPC 2.0 over the Streamable HTTP transport, tools/resources/prompts
|
||||
declared as plain classes and discovered at boot, optional OAuth2 protection built on
|
||||
`flash-ext-auth-oidc`.
|
||||
`flash-ext-security-core`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -44,7 +44,7 @@ public class GetWeatherTool extends McpTool {
|
||||
`tools-resources-prompts.md`.
|
||||
- **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
|
||||
for exactly what that means and why.
|
||||
- **Security**: optional, policy-driven OAuth2 via `flash-ext-auth-oidc` — see `security.md`.
|
||||
- **Security**: authenticated by `flash-ext-security-core`, an OAuth2 protected resource when OIDC is installed — see `security.md`.
|
||||
- **JSON**: this extension owns its JSON handling independently of `flash-ext-jackson` — see
|
||||
`jackson-interop.md` for why, and how a future opt-in reuse could work.
|
||||
|
||||
@@ -53,6 +53,4 @@ public class GetWeatherTool extends McpTool {
|
||||
- [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts
|
||||
- [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation
|
||||
- [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707
|
||||
- [`keycloak.md`](keycloak.md) — Keycloak-specific setup cookbook: Dynamic Client Registration,
|
||||
the RFC 8707 audience mapper gotcha, and how to verify/debug it
|
||||
- [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson`
|
||||
|
||||
@@ -24,7 +24,7 @@ see `tools-resources-prompts.md` — and the fixed `TextContent`/`TextResourceCo
|
||||
as a `JsonNode` tree, not as a databound class, for the same reason — a JSON-RPC tool call's
|
||||
arguments aren't a DTO with getters/setters, they're a dynamic, per-tool-defined bag of values.
|
||||
|
||||
This mirrors how `flash-ext-auth-oidc` already handles its own internal JSON needs (`json-smart` for
|
||||
This mirrors how `flash-ext-security-oidc` handles its own JSON needs (Nimbus's parser for
|
||||
token-endpoint responses) independently of `flash-ext-jackson` — extensions with protocol-level
|
||||
JSON needs that are shaped by a spec, not by user code, own that JSON handling themselves rather
|
||||
than routing it through the app's general-purpose JSON extension.
|
||||
@@ -44,8 +44,7 @@ Nothing here rules out a later, additive convenience layer: `McpExtension.routes
|
||||
that shared mapper as the backing for an escape hatch such as `ToolArguments.as(Class<T>)` or
|
||||
for a tool that wants to `ToolResponse.success(someRecord)` and have it serialized with the
|
||||
app's own conventions — falling back to a locally-constructed default `ObjectMapper` when
|
||||
`flash-ext-jackson` isn't installed, the same "prefer shared, degrade to sane default" shape
|
||||
already used for `McpSecurity.AUTO`. That would be purely additive on top of the
|
||||
`flash-ext-jackson` isn't installed. That would be purely additive on top of the
|
||||
`JsonGenerator`-based envelope/content writing described above, not a replacement for it — the
|
||||
fixed-shape protocol plumbing has no reason to ever go through databinding, regardless of what
|
||||
convenience layer gets added around it.
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# 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:
|
||||
|
||||
1. **Client scopes → `basic`** → **Mappers** → **Add mapper** → **By configuration** →
|
||||
**Audience**.
|
||||
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`.
|
||||
@@ -1,188 +1,47 @@
|
||||
# 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.
|
||||
The MCP endpoint is secured by [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md):
|
||||
whatever mechanisms the application registers — OAuth2 bearer tokens, API keys, custom ones —
|
||||
authenticate `/mcp` exactly as they authenticate every other route.
|
||||
|
||||
## `McpSecurity`
|
||||
| `McpConfig.security(...)` | |
|
||||
|---|---|
|
||||
| `REQUIRED` (default) | every call must be authenticated; boot fails without a `SecurityExtension` |
|
||||
| `NONE` | a public endpoint; a tool carrying security annotations fails the boot |
|
||||
|
||||
`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()`:
|
||||
## OAuth2 protected resource
|
||||
|
||||
| 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 |
|
||||
When a registered mechanism publishes an OAuth2 issuer — `flash-ext-security-oidc` does — the endpoint
|
||||
behaves as the MCP authorization spec requires, with nothing to configure:
|
||||
|
||||
### Guarding `/mcp` without OAuth2
|
||||
- `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata: the `resource` (derived per
|
||||
request from `X-Forwarded-Proto`/`-Host` or `Host`), every issuer as `authorization_servers`, and
|
||||
`scopes_supported` when `McpConfig.scopesSupported(...)` is set;
|
||||
- an anonymous call gets `401` with `WWW-Authenticate: Bearer resource_metadata="…"`;
|
||||
- a token whose `aud` does not include the resource is `403` (RFC 8707) and logged at `WARN`. Credentials
|
||||
that are not audience-bound, such as API keys, are unaffected.
|
||||
|
||||
`McpSecurity` only ever answers "is `flash-ext-auth-oidc` installed". An app that authenticates
|
||||
some other way sets `McpSecurity.NONE` and supplies its own guard:
|
||||
For Keycloak, the audience comes from an *Audience* protocol mapper whose included custom audience is
|
||||
the resource URL, attached to a client scope every MCP client receives (the built-in `basic` scope is the
|
||||
one that needs no client cooperation). Clients that register dynamically need Keycloak's anonymous
|
||||
client registration policies relaxed for the trusted hosts.
|
||||
|
||||
`McpConfig.requireTokenAudience(false)` drops that last check for an authorization server that cannot
|
||||
mint a resource audience at all — Keycloak ignores RFC 8707's `resource` parameter, so a deployment that
|
||||
cannot add the mapper has no other way in. Every token a registered issuer signs is then accepted on the
|
||||
endpoint, and the boot logs say so.
|
||||
|
||||
## Tool policies
|
||||
|
||||
The core annotations work on tools as on handlers, checked per `tools/call` against the caller the route
|
||||
authenticated:
|
||||
|
||||
```java
|
||||
McpConfig.builder("my-server")
|
||||
.toolsPackage("com.example.mcp")
|
||||
.security(McpSecurity.NONE)
|
||||
.middleware(myAuthMiddleware.protect())
|
||||
.build();
|
||||
@Tool(name = "approve", description = "Approves a pending proposal")
|
||||
@RolesAllowed(value = "REVIEWER", on = {"project", "locale"}) // read from the tool's arguments
|
||||
public class ApproveTool extends McpTool { … }
|
||||
```
|
||||
|
||||
`McpConfig.middleware(...)` runs after the transport guards and after whatever `McpSecurity`
|
||||
resolved to, so it composes with OAuth2 protection rather than replacing it — the same hook is how
|
||||
you add rate limiting, audit logging or tracing to the endpoint. It never satisfies `REQUIRED`,
|
||||
which still asks for a real authorization server.
|
||||
A denial is a tool result with `isError: true` — the call reached the server, the tool did not run.
|
||||
|
||||
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.
|
||||
`McpConfig.middleware(...)` runs after authentication, for rate limiting, auditing or tracing.
|
||||
|
||||
Reference in New Issue
Block a user