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.
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-auth-oidc</artifactId>
|
||||
<optional>true</optional>
|
||||
<artifactId>flash-ext-security-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
@@ -43,6 +42,22 @@
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-security-test</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-security-oidc</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-security-apikey</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Compiled per-tool authorization requirement, built once at boot by {@link McpOidcIntegration}
|
||||
* from {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} subclass — {@code null}
|
||||
* on {@link McpRegistry.RegisteredTool} means no restriction beyond whatever {@link McpSecurity}
|
||||
* already enforces route-wide.
|
||||
*
|
||||
* <p>{@code check} is a closure, not a raw role/scope list — this is what lets this record (and
|
||||
* its only caller, {@link McpDispatcher}) stay free of any compile-time reference to a {@code
|
||||
* flash-ext-auth-oidc} type, preserving the same classload isolation {@link McpOidcIntegration}'s
|
||||
* javadoc describes for the rest of the OIDC bridge. Only the plain-JDK {@link Supplier}
|
||||
* signature crosses the boundary; the closure itself, built once inside {@code
|
||||
* McpOidcIntegration}, is the only place that ever touches {@code OidcUser}/{@code ClaimsHolder}.
|
||||
*
|
||||
* <p>Returns {@code null} from {@link #check()}{@code .get()} when authorized, or a
|
||||
* human-readable denial reason otherwise — invoked once per {@code tools/call} against an
|
||||
* annotated tool, never allocated on that path (the closure and its captured role/scope arrays
|
||||
* are built exactly once, at boot).
|
||||
*/
|
||||
record McpAuthPolicy(Supplier<String> check) {}
|
||||
+13
-49
@@ -26,8 +26,7 @@ public final class McpConfig {
|
||||
private final String rootPath;
|
||||
private final String toolsPackage;
|
||||
private final McpSecurity security;
|
||||
private final String resourceIdentifier;
|
||||
private final String authorizationServerIssuer;
|
||||
private final boolean requireTokenAudience;
|
||||
private final List<String> allowedOrigins;
|
||||
private final List<String> scopesSupported;
|
||||
private final List<Middleware> middleware;
|
||||
@@ -39,8 +38,7 @@ public final class McpConfig {
|
||||
this.rootPath = b.rootPath;
|
||||
this.toolsPackage = b.toolsPackage;
|
||||
this.security = b.security;
|
||||
this.resourceIdentifier = b.resourceIdentifier;
|
||||
this.authorizationServerIssuer = b.authorizationServerIssuer;
|
||||
this.requireTokenAudience = b.requireTokenAudience;
|
||||
this.allowedOrigins = List.copyOf(b.allowedOrigins);
|
||||
this.scopesSupported = List.copyOf(b.scopesSupported);
|
||||
this.middleware = List.copyOf(b.middleware);
|
||||
@@ -52,8 +50,7 @@ public final class McpConfig {
|
||||
String rootPath() { return rootPath; }
|
||||
String toolsPackage() { return toolsPackage; }
|
||||
McpSecurity security() { return security; }
|
||||
String resourceIdentifier() { return resourceIdentifier; }
|
||||
String authorizationServerIssuer() { return authorizationServerIssuer; }
|
||||
boolean requireTokenAudience() { return requireTokenAudience; }
|
||||
List<String> allowedOrigins() { return allowedOrigins; }
|
||||
List<String> scopesSupported() { return scopesSupported; }
|
||||
List<Middleware> middleware() { return middleware; }
|
||||
@@ -66,9 +63,8 @@ public final class McpConfig {
|
||||
private String instructions;
|
||||
private String rootPath = "/mcp";
|
||||
private String toolsPackage;
|
||||
private McpSecurity security = McpSecurity.AUTO;
|
||||
private String resourceIdentifier;
|
||||
private String authorizationServerIssuer;
|
||||
private McpSecurity security = McpSecurity.REQUIRED;
|
||||
private boolean requireTokenAudience = true;
|
||||
private final List<String> allowedOrigins = new ArrayList<>();
|
||||
private final List<String> scopesSupported = new ArrayList<>();
|
||||
private final List<Middleware> middleware = new ArrayList<>();
|
||||
@@ -91,28 +87,16 @@ public final class McpConfig {
|
||||
/** Package scanned for {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes. Required. */
|
||||
public Builder toolsPackage(String toolsPackage) { this.toolsPackage = toolsPackage; return this; }
|
||||
|
||||
/** OAuth2 requirement policy. Default {@link McpSecurity#AUTO}. */
|
||||
/** Default {@link McpSecurity#REQUIRED}. */
|
||||
public Builder security(McpSecurity security) { this.security = security; return this; }
|
||||
|
||||
/**
|
||||
* Canonical URI of this MCP endpoint, used for RFC 8707 audience binding: tokens whose
|
||||
* {@code aud} claim does not include this value are rejected. Optional — when
|
||||
* {@code flash-ext-auth-oidc} is installed, this is auto-derived per request from the
|
||||
* forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own
|
||||
* redirect URIs) and audience binding is enforced unconditionally. Set this explicitly
|
||||
* only to override that guess — a reverse proxy that forwards neither
|
||||
* {@code X-Forwarded-Host} nor {@code X-Forwarded-Proto}.
|
||||
* Whether a bearer token must name this endpoint in its {@code aud} (RFC 8707), as the MCP
|
||||
* authorization spec requires. Default {@code true}. Turn it off for an authorization server
|
||||
* that cannot mint a resource audience — every token a registered issuer signs is then
|
||||
* accepted on the endpoint, and a warning is logged at boot.
|
||||
*/
|
||||
public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; }
|
||||
|
||||
/**
|
||||
* Authorization server issuer URL, published in the RFC 9728 Protected Resource
|
||||
* Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}}. Optional
|
||||
* — when {@code flash-ext-auth-oidc} is installed, this is auto-derived from its configured
|
||||
* issuer. Set this explicitly only to override that (e.g. publishing a different issuer
|
||||
* than the one actually validating tokens).
|
||||
*/
|
||||
public Builder authorizationServerIssuer(String issuer) { this.authorizationServerIssuer = issuer; return this; }
|
||||
public Builder requireTokenAudience(boolean require) { this.requireTokenAudience = require; return this; }
|
||||
|
||||
/**
|
||||
* Origins allowed to call the MCP endpoint (DNS-rebinding protection, per the Streamable
|
||||
@@ -121,30 +105,10 @@ public final class McpConfig {
|
||||
*/
|
||||
public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; }
|
||||
|
||||
/**
|
||||
* OAuth2 scopes this server expects clients to request, published as {@code
|
||||
* scopes_supported} in the RFC 9728 Protected Resource Metadata document. Optional per
|
||||
* the spec — omitted from the document entirely if never set. A spec-compliant client
|
||||
* reads this to know what to put in its authorization/token requests instead of
|
||||
* requesting nothing; see {@code docs/keycloak.md}'s "same story for any other claim"
|
||||
* section for why this matters in practice (a client that requests no scope only gets
|
||||
* whatever your authorization server treats as always-included, e.g. Keycloak's `basic`).
|
||||
* Purely advertisement — this server still validates whatever token it actually receives
|
||||
* the same way regardless of what a client requested.
|
||||
*/
|
||||
/** Published as {@code scopes_supported} in the RFC 9728 metadata, so OAuth clients request them. */
|
||||
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
|
||||
|
||||
/**
|
||||
* Middleware to run on the MCP route, in the order given, after the transport guards and
|
||||
* after whatever {@link McpSecurity} resolved to. Rate limiting, audit logging, tracing —
|
||||
* anything that is routine on every other Flash route and had no way in here.
|
||||
*
|
||||
* <p>It runs on an authenticated request when OAuth2 protection is active, and is the only
|
||||
* thing standing in front of the endpoint when it is not: {@link McpSecurity#NONE} plus a
|
||||
* middleware of your own is how an app that authenticates some other way guards
|
||||
* {@code /mcp}. It never satisfies {@link McpSecurity#REQUIRED}, which still asks for a
|
||||
* real authorization server.
|
||||
*/
|
||||
/** Runs on the MCP route after the transport guards and authentication — rate limiting, auditing, tracing. */
|
||||
public Builder middleware(Middleware... middleware) {
|
||||
this.middleware.addAll(List.of(middleware));
|
||||
return this;
|
||||
|
||||
+11
-7
@@ -3,6 +3,8 @@ package dev.relism.flash.ext.mcp;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.ext.security.SecurityIdentity;
|
||||
import dev.relism.flash.ext.security.SecurityPolicy;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
|
||||
@@ -18,10 +20,10 @@ import java.io.IOException;
|
||||
* tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object,
|
||||
* always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only
|
||||
* malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. A
|
||||
* {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link McpAuthPolicy}) is the same
|
||||
* {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link SecurityPolicy}) is the same
|
||||
* category — {@code isError: true}, tool never invoked — not a transport-level rejection; the
|
||||
* route-wide 401/403 for "not authenticated at all" already happened earlier, in the {@code
|
||||
* OidcMiddleware}/audience-guard middleware chain, before this dispatcher ever runs.
|
||||
* route-wide 401/403 already happened earlier, in the security middleware, before this
|
||||
* dispatcher ever runs.
|
||||
*/
|
||||
final class McpDispatcher {
|
||||
|
||||
@@ -136,12 +138,14 @@ final class McpDispatcher {
|
||||
if (tool == null)
|
||||
throw McpProtocolException.invalidParams("Unknown tool: " + name);
|
||||
|
||||
ToolArguments args = new ToolArguments(params.path("arguments"));
|
||||
SecurityPolicy policy = tool.policy();
|
||||
ToolResponse result;
|
||||
String denied = tool.policy() != null ? tool.policy().check().get() : null;
|
||||
if (denied != null) {
|
||||
result = ToolResponse.error("Tool \"" + name + "\" denied: " + denied);
|
||||
if (policy != null && !policy.permitsScopes(SecurityIdentity.current())) {
|
||||
result = ToolResponse.error("Tool \"" + name + "\" denied: missing scope");
|
||||
} else if (policy != null && !policy.permitsRoles(SecurityIdentity.current(), args::getString)) {
|
||||
result = ToolResponse.error("Tool \"" + name + "\" denied: missing role");
|
||||
} else {
|
||||
ToolArguments args = new ToolArguments(params.path("arguments"));
|
||||
try {
|
||||
result = tool.instance().call(args);
|
||||
} catch (Exception e) {
|
||||
|
||||
+64
-87
@@ -1,5 +1,10 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.ext.security.SecurityExtension;
|
||||
import dev.relism.flash.ext.security.SecurityIdentity;
|
||||
import dev.relism.flash.ext.security.SecurityPolicy;
|
||||
import dev.relism.flash.ext.security.SecurityScheme;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
@@ -9,38 +14,25 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single
|
||||
* {@code POST} JSON-RPC endpoint, stateless in this revision (no session, no SSE stream; see
|
||||
* {@code docs/transport.md}) — dispatch precompiled at boot from classes annotated with
|
||||
* {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} under
|
||||
* MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single {@code POST}
|
||||
* JSON-RPC endpoint, stateless in this revision (see {@code docs/transport.md}) — dispatching to
|
||||
* {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes under
|
||||
* {@link McpConfig#toolsPackage(String)}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Standalone, no OAuth2
|
||||
* FlashApp.create(8080)
|
||||
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
|
||||
* .toolsPackage("com.example.tools")
|
||||
* .build()))
|
||||
* .start();
|
||||
*
|
||||
* // With flash-ext-auth-oidc as the OAuth2 resource server — zero extra config: issuer, canonical
|
||||
* // resource identifier, RFC 8707 audience binding and RFC 9728 metadata are all derived from
|
||||
* // the installed OidcExtension.
|
||||
* FlashApp.create(8080)
|
||||
* .install(new OidcExtension(oidcConfig))
|
||||
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
|
||||
* .toolsPackage("com.example.tools")
|
||||
* .security(McpSecurity.REQUIRED)
|
||||
* .build()))
|
||||
* .start();
|
||||
* app.install(new SecurityExtension())
|
||||
* .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)))
|
||||
* .install(new McpExtension(McpConfig.builder("my-server").toolsPackage("com.example.tools").build()));
|
||||
* }</pre>
|
||||
*
|
||||
* <p>One server per {@code McpExtension} instance — install multiple instances (distinct
|
||||
* {@code rootPath}, distinct {@code toolsPackage}) for multiple MCP servers on one app,
|
||||
* mirroring the {@code OidcExtension} multi-tenant pattern. See {@code docs/security.md} for
|
||||
* the full OAuth2 resolution rules.
|
||||
* <p>Every call is authenticated by the application's security chain — OAuth2 bearer tokens, API
|
||||
* keys, anything registered. When an OAuth2 issuer is among its schemes, the endpoint is also an
|
||||
* OAuth2 protected resource: RFC 9728 metadata, a {@code resource_metadata} challenge, and RFC 8707
|
||||
* audience binding for audience-bound tokens. Tool annotations are enforced per call, with
|
||||
* {@code @RolesAllowed(on = ...)} reading tool arguments.
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpExtension implements FlashExtension {
|
||||
@@ -53,69 +45,54 @@ public class McpExtension implements FlashExtension {
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.onReady(() -> registerRoutes(app, ctx));
|
||||
}
|
||||
ctx.onReady(() -> {
|
||||
SecurityExtension security = config.security() == McpSecurity.NONE ? null : ctx.find(SecurityExtension.class)
|
||||
.orElseThrow(() -> new IllegalStateException("MCP server \"" + config.name()
|
||||
+ "\" requires flash-ext-security-core: install a SecurityExtension, or set McpSecurity.NONE for a public server"));
|
||||
McpDispatcher dispatcher = new McpDispatcher(McpRegistry.scan(config.toolsPackage(), ctx, security),
|
||||
config.name(), config.version(), config.instructions());
|
||||
|
||||
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
// Resolved before scanning so McpRegistry knows, per tool, whether @RolesAllowed/
|
||||
// @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration
|
||||
// (see McpOidcIntegration#compileToolPolicy) — must run first, not after.
|
||||
McpOidcIntegration.Resolved secured = resolveSecurity(ctx);
|
||||
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null,
|
||||
secured == null ? null : secured.rolesClaimPath());
|
||||
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
|
||||
|
||||
List<Middleware> chain = new ArrayList<>(3 + config.middleware().size());
|
||||
chain.add(McpTransportGuards.httpExceptionGuard());
|
||||
chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
|
||||
if (secured != null) chain.add(secured.security());
|
||||
chain.addAll(config.middleware());
|
||||
|
||||
app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; },
|
||||
chain.toArray(Middleware[]::new));
|
||||
|
||||
registerResourceMetadata(app, secured);
|
||||
}
|
||||
|
||||
private McpOidcIntegration.Resolved resolveSecurity(FlashContext ctx) {
|
||||
if (config.security() == McpSecurity.NONE) return null;
|
||||
|
||||
McpOidcIntegration.Resolved resolved;
|
||||
try {
|
||||
resolved = McpOidcIntegration.resolve(ctx, config);
|
||||
} catch (NoClassDefFoundError e) {
|
||||
resolved = null; // flash-ext-auth-oidc not on the classpath at all
|
||||
}
|
||||
if (resolved != null) return resolved;
|
||||
|
||||
if (config.security() == McpSecurity.REQUIRED) {
|
||||
throw new IllegalStateException(
|
||||
"McpSecurity.REQUIRED but flash-ext-auth-oidc is not installed for MCP server \"" + config.name() +
|
||||
"\" — install an OidcExtension before this McpExtension, or relax security to " +
|
||||
"McpSecurity.AUTO/NONE if this server is meant to be public.");
|
||||
}
|
||||
|
||||
log.warn("[flash-ext-mcp] MCP server \"{}\" is running WITHOUT OAuth2 protection — " +
|
||||
"flash-ext-auth-oidc is not installed and McpSecurity.AUTO degrades to unprotected. " +
|
||||
"Install flash-ext-auth-oidc or set McpSecurity.REQUIRED to make this a hard failure instead.",
|
||||
config.name());
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9728 Protected Resource Metadata, built once security is resolved — no longer
|
||||
* conditioned on {@code resourceIdentifier}/{@code authorizationServerIssuer} being set
|
||||
* explicitly, since {@link McpOidcIntegration#resolve} now derives both by default. The
|
||||
* {@code resource} field is computed per request (it depends on that request's own
|
||||
* forwarded/{@code Host} headers) via {@link McpOidcIntegration.Resolved#resourceIdentifier()}.
|
||||
*/
|
||||
private void registerResourceMetadata(FlashRegistrar<?> app, McpOidcIntegration.Resolved secured) {
|
||||
if (secured == null) return;
|
||||
String path = "/.well-known/oauth-protected-resource" + config.rootPath();
|
||||
app.get(path, (req, res) -> {
|
||||
res.type(ContentType.JSON);
|
||||
return McpResourceMetadata.build(
|
||||
secured.resourceIdentifier().apply(req), secured.issuer(), config.scopesSupported());
|
||||
List<Middleware> chain = new ArrayList<>(List.of(
|
||||
McpTransportGuards.httpExceptionGuard(), McpTransportGuards.originGuard(config.allowedOrigins())));
|
||||
if (security != null) protect(app, security, chain);
|
||||
chain.addAll(config.middleware());
|
||||
app.post(config.rootPath(), (req, res) -> {
|
||||
dispatcher.handle(req, res);
|
||||
return null;
|
||||
}, chain.toArray(Middleware[]::new));
|
||||
});
|
||||
}
|
||||
|
||||
private void protect(FlashRegistrar<?> app, SecurityExtension security, List<Middleware> chain) {
|
||||
String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
|
||||
chain.add(security.enforce(SecurityPolicy.AUTHENTICATED, (req, res) -> {
|
||||
List<String> issuers = issuers(security);
|
||||
res.header("WWW-Authenticate", issuers.isEmpty()
|
||||
? String.join(", ", security.schemes().stream().map(SecurityScheme::challenge).toList())
|
||||
: "Bearer resource_metadata=\"" + req.origin() + metadataPath + "\"");
|
||||
throw HttpException.unauthorized();
|
||||
}));
|
||||
if (config.requireTokenAudience()) {
|
||||
chain.add(next -> (req, res) -> {
|
||||
String resource = req.origin() + config.rootPath();
|
||||
if (!SecurityIdentity.current().principal().hasAudience(resource)) {
|
||||
log.warn("[flash-ext-mcp] Rejected a token not issued for {} (RFC 8707) — the authorization server must put it in aud", resource);
|
||||
throw HttpException.forbidden();
|
||||
}
|
||||
return next.handle(req, res);
|
||||
});
|
||||
} else {
|
||||
log.warn("[flash-ext-mcp] Token audience validation (RFC 8707) is DISABLED for {} — every token a registered issuer signs is accepted.", config.rootPath());
|
||||
}
|
||||
app.get(metadataPath, (req, res) -> {
|
||||
List<String> issuers = issuers(security);
|
||||
if (issuers.isEmpty()) throw HttpException.notFound("Protected resource metadata");
|
||||
res.type(ContentType.JSON);
|
||||
return McpResourceMetadata.build(req.origin() + config.rootPath(), issuers, config.scopesSupported());
|
||||
});
|
||||
}
|
||||
|
||||
private static List<String> issuers(SecurityExtension security) {
|
||||
return security.schemes().stream().map(SecurityScheme::issuer).filter(Objects::nonNull).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import java.nio.charset.StandardCharsets;
|
||||
*
|
||||
* <p>Not wired to {@code flash-ext-jackson} on purpose: the MCP JSON-RPC envelope is internal
|
||||
* protocol plumbing, not a user-facing serialization concern, so this extension owns its
|
||||
* mapper independently — same reasoning {@code flash-ext-auth-oidc} applies to its own JSON needs
|
||||
* mapper independently — the same reasoning any protocol-level extension applies to its own JSON needs
|
||||
* (see {@code json-smart} there). See {@code docs/jackson-interop.md} for the full rationale
|
||||
* and how a future opt-in reuse of a shared {@code ObjectMapper} could work.
|
||||
*/
|
||||
|
||||
-186
@@ -1,186 +0,0 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.ext.auth.AuthMiddleware;
|
||||
import dev.relism.flash.ext.auth.Authenticated;
|
||||
import dev.relism.flash.ext.auth.Claims;
|
||||
import dev.relism.flash.ext.auth.ClaimsHolder;
|
||||
import dev.relism.flash.ext.auth.RolesAllowed;
|
||||
import dev.relism.flash.ext.auth.ScopesAllowed;
|
||||
import dev.relism.flash.ext.oidc.OidcCredentialSource;
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Lazy, isolated bridge to {@code flash-ext-auth-oidc} and {@code flash-ext-auth-core}.
|
||||
*
|
||||
* <p>References to OIDC types only ever resolve when {@link #resolve}/{@link #compileToolPolicy}
|
||||
* are actually invoked — never at {@link McpExtension} class-load time — because they live in
|
||||
* this separate nested class. The caller wraps the invocation in {@code catch
|
||||
* (NoClassDefFoundError)}, exactly like {@code OidcExtension}'s own lazy bridge to {@code
|
||||
* flash-ext-openapi}. This is what lets {@code flash-ext-mcp} run standalone (MCP-only, no
|
||||
* OAuth2) when {@code flash-ext-auth-oidc} is not even on the classpath. {@link Resolved}/{@link
|
||||
* McpAuthPolicy} carry only oidc-free types back out ({@link Middleware}, {@link String}, a
|
||||
* {@link Function}, a {@link Supplier}) so no other class in this package ever has to reference
|
||||
* an OIDC type.
|
||||
*
|
||||
* <p>Zero-config by design: when {@code flash-ext-auth-oidc} is installed, everything an MCP OAuth2
|
||||
* resource server needs — issuer, canonical resource identifier, RFC 8707 audience binding, and
|
||||
* a spec-compliant {@code WWW-Authenticate} challenge (RFC 9728 §5.1) — is derived straight from
|
||||
* the installed {@link OidcCredentialSource}, with no additional {@link McpConfig} calls.
|
||||
* {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)}
|
||||
* remain as explicit overrides for the rare case where that guess is wrong.
|
||||
*/
|
||||
@Slf4j
|
||||
final class McpOidcIntegration {
|
||||
|
||||
private static final String[] NO_VALUES = new String[0];
|
||||
|
||||
private McpOidcIntegration() {}
|
||||
|
||||
/** Everything {@link McpExtension} needs once oidc security is resolved. */
|
||||
record Resolved(Middleware security, String issuer, String rolesClaimPath,
|
||||
Function<Request, String> resourceIdentifier) {}
|
||||
|
||||
/** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
|
||||
static Resolved resolve(FlashContext ctx, McpConfig config) {
|
||||
// Deliberately keyed on the OIDC source and not on AuthMiddleware: McpSecurity means
|
||||
// "a real OAuth2 authorization server is protecting this endpoint", and an app that
|
||||
// authenticates some other way must not satisfy REQUIRED by accident.
|
||||
Optional<OidcCredentialSource> oidc = ctx.find(OidcCredentialSource.class);
|
||||
Optional<AuthMiddleware> auth = ctx.find(AuthMiddleware.class);
|
||||
if (oidc.isEmpty() || auth.isEmpty()) return null;
|
||||
|
||||
OidcCredentialSource source = oidc.get();
|
||||
AuthMiddleware authMw = auth.get();
|
||||
String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
|
||||
String issuer = config.authorizationServerIssuer() != null
|
||||
? config.authorizationServerIssuer() : source.issuer();
|
||||
Function<Request, String> resourceId = req -> config.resourceIdentifier() != null
|
||||
? config.resourceIdentifier()
|
||||
: OidcCredentialSource.selfOrigin(req, source.selfScheme()) + config.rootPath();
|
||||
|
||||
Middleware protect = authMw.withSource(source.withResourceMetadata(resourceMetadataPath)).protect();
|
||||
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
|
||||
return new Resolved(secured, issuer, authMw.rolesClaimPath(), resourceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 8707 audience binding, unconditionally enforced once oidc is protecting the MCP
|
||||
* route — no longer opt-in behind an explicit {@code resourceIdentifier(...)} call.
|
||||
*/
|
||||
private static Middleware audienceGuard(Function<Request, String> resourceIdentifier) {
|
||||
return next -> (req, res) -> {
|
||||
Map<String, Object> claims = ClaimsHolder.map();
|
||||
String expected = resourceIdentifier.apply(req);
|
||||
if (claims != null && !audienceMatches(claims.get("aud"), expected)) {
|
||||
log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " +
|
||||
"resource identifier \"{}\" — the authorization server must include this exact " +
|
||||
"value in the access token's aud claim (e.g. an Audience protocol mapper in " +
|
||||
"Keycloak) for this MCP server to accept it.", claims.get("aud"), expected);
|
||||
throw HttpException.forbidden();
|
||||
}
|
||||
return next.handle(req, res);
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean audienceMatches(Object aud, String expected) {
|
||||
if (aud instanceof String s) return s.equals(expected);
|
||||
if (aud instanceof Iterable<?> it) {
|
||||
for (Object o : it) if (expected.equals(String.valueOf(o))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool class into a {@link
|
||||
* McpAuthPolicy}, or returns {@code null} if the tool carries none of the three OIDC
|
||||
* annotations. Called once per tool at boot ({@link McpRegistry#scan}), never on the
|
||||
* request hot path — the {@link Supplier} it returns is what runs per {@code tools/call},
|
||||
* closing over the already-normalized role/scope arrays so the hot path itself allocates
|
||||
* nothing beyond what {@link Claims#hasRole}/{@link Claims#hasScope} already do.
|
||||
*
|
||||
* <p>Fails fast at boot, not silently at request time, for the two ways this can be
|
||||
* misconfigured: the annotation present without OAuth2 actually protecting this MCP server
|
||||
* ({@code oidcActive == false}), and {@code @Authenticated} — which has no per-tool meaning
|
||||
* here (see below) — used at all.
|
||||
*/
|
||||
static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> toolClass, boolean oidcActive,
|
||||
String rolesClaimPath) {
|
||||
Authenticated auth = toolClass.getAnnotation(Authenticated.class);
|
||||
RolesAllowed roles = toolClass.getAnnotation(RolesAllowed.class);
|
||||
ScopesAllowed scopes = toolClass.getAnnotation(ScopesAllowed.class);
|
||||
if (auth == null && roles == null && scopes == null) return null;
|
||||
|
||||
if (!oidcActive) {
|
||||
throw new IllegalStateException(
|
||||
"MCP tool \"" + toolClass.getSimpleName() + "\" declares @Authenticated/@RolesAllowed/" +
|
||||
"@ScopesAllowed, but this MCP server has no active OAuth2 protection — flash-ext-auth-oidc " +
|
||||
"is not installed for it, or McpSecurity is NONE. These annotations require " +
|
||||
"McpSecurity.AUTO/REQUIRED with an OidcExtension installed; install one, or remove the " +
|
||||
"annotation from " + toolClass.getSimpleName() + ".");
|
||||
}
|
||||
if (auth != null) {
|
||||
throw new IllegalStateException(
|
||||
"MCP tool \"" + toolClass.getSimpleName() + "\" is annotated @Authenticated, which has " +
|
||||
"no effect on an McpTool: the whole MCP endpoint is already all-or-nothing " +
|
||||
"authenticated once oidc is active (McpSecurity.AUTO/REQUIRED) — unlike a RequestHandler " +
|
||||
"route, there is no per-tool public/authenticated split to opt into. Remove it, or use " +
|
||||
"@RolesAllowed/@ScopesAllowed to narrow further.");
|
||||
}
|
||||
|
||||
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : NO_VALUES;
|
||||
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : NO_VALUES;
|
||||
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
|
||||
|
||||
Supplier<String> check = () -> {
|
||||
Claims user = ClaimsHolder.current();
|
||||
if (user == null) return "not authenticated";
|
||||
if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles))
|
||||
return "missing required role (any of: " + String.join(", ", requiredRoles) + ")";
|
||||
if (requiredScopes.length > 0 && !hasScopes(user, requiredScopes, scopeMatch))
|
||||
return "missing required scope (" + scopeMatch + " of: " + String.join(", ", requiredScopes) + ")";
|
||||
return null;
|
||||
};
|
||||
return new McpAuthPolicy(check);
|
||||
}
|
||||
|
||||
private static boolean hasAnyRole(Claims user, String claimPath, String[] roles) {
|
||||
for (String role : roles) if (user.hasRole(claimPath, role)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasScopes(Claims user, String[] scopes, ScopesAllowed.Match match) {
|
||||
if (match == ScopesAllowed.Match.ALL) {
|
||||
for (String scope : scopes) if (!user.hasScope(scope)) return false;
|
||||
return true;
|
||||
}
|
||||
for (String scope : scopes) if (user.hasScope(scope)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Mirrors {@code OidcAuthPolicy}'s own normalization — trim, dedupe, require non-blank. */
|
||||
private static String[] normalizeRequired(String annotationName, String[] values) {
|
||||
if (values == null || values.length == 0)
|
||||
throw new IllegalStateException("@" + annotationName + " requires at least one value");
|
||||
|
||||
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
|
||||
for (String raw : values) {
|
||||
if (raw == null) continue;
|
||||
String trimmed = raw.trim();
|
||||
if (!trimmed.isEmpty()) normalized.add(trimmed);
|
||||
}
|
||||
if (normalized.isEmpty())
|
||||
throw new IllegalStateException("@" + annotationName + " requires at least one non-empty value");
|
||||
|
||||
return normalized.toArray(String[]::new);
|
||||
}
|
||||
}
|
||||
+8
-29
@@ -2,6 +2,8 @@ package dev.relism.flash.ext.mcp;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import dev.relism.flash.exceptions.InitializationException;
|
||||
import dev.relism.flash.ext.security.SecurityExtension;
|
||||
import dev.relism.flash.ext.security.SecurityPolicy;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -25,7 +27,7 @@ final class McpRegistry {
|
||||
private static final String EMPTY_ARRAY = "[]";
|
||||
|
||||
/** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */
|
||||
record RegisteredTool(String name, McpTool instance, McpAuthPolicy policy) {}
|
||||
record RegisteredTool(String name, McpTool instance, SecurityPolicy policy) {}
|
||||
record RegisteredResource(String uri, McpResource instance) {}
|
||||
record RegisteredPrompt(String name, McpPrompt instance) {}
|
||||
|
||||
@@ -39,15 +41,8 @@ final class McpRegistry {
|
||||
|
||||
private McpRegistry() {}
|
||||
|
||||
/**
|
||||
* @param oidcActive whether this MCP server's route is actually OAuth2-protected right
|
||||
* now (see {@link McpOidcIntegration#resolve}) — gates whether
|
||||
* {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or
|
||||
* rejected at boot as a misconfiguration; see
|
||||
* {@link McpOidcIntegration#compileToolPolicy}.
|
||||
* @param rolesClaimPath claim path resolved from the installed OIDC extension.
|
||||
*/
|
||||
static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) {
|
||||
/** @param security {@code null} for a server running with {@link McpSecurity#NONE} */
|
||||
static McpRegistry scan(String packageName, FlashContext ctx, SecurityExtension security) {
|
||||
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
|
||||
McpRegistry registry = new McpRegistry();
|
||||
|
||||
@@ -55,7 +50,9 @@ final class McpRegistry {
|
||||
Tool ann = cls.getAnnotation(Tool.class);
|
||||
McpTool instance = instantiate(cls);
|
||||
instance.bind(ctx);
|
||||
McpAuthPolicy policy = compileToolPolicy(cls, oidcActive, rolesClaimPath);
|
||||
if (security == null && SecurityPolicy.of(cls) != null)
|
||||
throw new InitializationException("MCP tool \"" + ann.name() + "\" declares security annotations, but the server runs with McpSecurity.NONE");
|
||||
SecurityPolicy policy = security == null ? null : security.policy(cls);
|
||||
if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null)
|
||||
throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\"");
|
||||
}
|
||||
@@ -173,24 +170,6 @@ final class McpRegistry {
|
||||
gen.writeEndArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Isolated the same way {@link McpOidcIntegration#resolve} is — {@code
|
||||
* NoClassDefFoundError} here means {@code flash-ext-auth-oidc} genuinely isn't on the runtime
|
||||
* classpath, in which case a tool couldn't have been compiled against
|
||||
* {@code @RolesAllowed}/{@code @ScopesAllowed} in the first place, so there's nothing to
|
||||
* check (and nothing lost: {@code oidcActive} is only ever {@code true} once {@link
|
||||
* McpOidcIntegration#resolve} has already succeeded once this boot, which proves those
|
||||
* types resolve fine).
|
||||
*/
|
||||
private static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> cls, boolean oidcActive,
|
||||
String rolesClaimPath) {
|
||||
try {
|
||||
return McpOidcIntegration.compileToolPolicy(cls, oidcActive, rolesClaimPath);
|
||||
} catch (NoClassDefFoundError e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> T instantiate(Class<T> cls) {
|
||||
try {
|
||||
Constructor<T> ctor = cls.getDeclaredConstructor();
|
||||
|
||||
+5
-5
@@ -2,18 +2,18 @@ package dev.relism.flash.ext.mcp;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */
|
||||
/** RFC 9728 OAuth 2.0 Protected Resource Metadata. */
|
||||
final class McpResourceMetadata {
|
||||
|
||||
private McpResourceMetadata() {}
|
||||
|
||||
/** {@code scopesSupported} is optional per RFC 9728 — omitted from the document if empty. */
|
||||
static String build(String resourceIdentifier, String authorizationServerIssuer, List<String> scopesSupported) {
|
||||
/** {@code scopesSupported} is optional — omitted when empty. */
|
||||
static String build(String resource, List<String> authorizationServers, List<String> scopesSupported) {
|
||||
return McpJson.buildString(gen -> {
|
||||
gen.writeStartObject();
|
||||
gen.writeStringField("resource", resourceIdentifier);
|
||||
gen.writeStringField("resource", resource);
|
||||
gen.writeArrayFieldStart("authorization_servers");
|
||||
gen.writeString(authorizationServerIssuer);
|
||||
for (String issuer : authorizationServers) gen.writeString(issuer);
|
||||
gen.writeEndArray();
|
||||
if (!scopesSupported.isEmpty()) {
|
||||
gen.writeArrayFieldStart("scopes_supported");
|
||||
|
||||
+3
-9
@@ -1,17 +1,11 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
/**
|
||||
* OAuth2 requirement policy for the MCP endpoint, resolved against whether
|
||||
* {@code flash-ext-auth-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
|
||||
*/
|
||||
/** Whether the MCP endpoint requires an authenticated caller. */
|
||||
public enum McpSecurity {
|
||||
|
||||
/** Fail fast at boot if {@code flash-ext-auth-oidc} is not installed — never expose an unprotected MCP endpoint. */
|
||||
/** The default: every call is authenticated by {@code flash-ext-security-core}, which must be installed. */
|
||||
REQUIRED,
|
||||
|
||||
/** Protect the endpoint if {@code flash-ext-auth-oidc} is installed; otherwise run unprotected and log a warning. */
|
||||
AUTO,
|
||||
|
||||
/** Never protect the endpoint, even if {@code flash-ext-auth-oidc} is installed elsewhere in the app. */
|
||||
/** A public endpoint. Tools declaring security annotations fail the boot. */
|
||||
NONE
|
||||
}
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ final class McpTransportGuards {
|
||||
* allowed through — only a <em>present but disallowed</em> value is rejected.
|
||||
*
|
||||
* <p>If {@code allowedOrigins} is empty, validation is skipped and a boot-time warning is
|
||||
* logged — same graceful-degradation shape as {@link McpSecurity#AUTO}.
|
||||
* logged.
|
||||
*/
|
||||
static Middleware originGuard(List<String> allowedOrigins) {
|
||||
if (allowedOrigins.isEmpty()) {
|
||||
@@ -38,7 +38,7 @@ final class McpTransportGuards {
|
||||
|
||||
/**
|
||||
* Safety net around the whole MCP route: translates {@link HttpException} (thrown by
|
||||
* {@link #originGuard} or by {@code flash-ext-auth-oidc}'s middleware) into a proper HTTP status
|
||||
* {@link #originGuard} or by {@code flash-ext-security-core}) into a proper HTTP status
|
||||
* directly, instead of relying on the app's global exception handler — which defaults to a
|
||||
* generic 500 for every exception type unless the app owner overrides it (see
|
||||
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint
|
||||
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.JWSHeader;
|
||||
import com.nimbusds.jose.crypto.RSASSASigner;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.KeyUse;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Minimal, self-contained fake OIDC provider for tests: real discovery document, real JWKS
|
||||
* endpoint, real RS256-signed tokens — no network dependency beyond localhost, no mocking
|
||||
* framework. Exercises {@code flash-ext-auth-oidc}'s actual discovery + JWKS + JWT validation path.
|
||||
*/
|
||||
final class FakeOidcProvider implements AutoCloseable {
|
||||
|
||||
private final HttpServer server;
|
||||
private final String issuer;
|
||||
private final RSAKey rsaKey;
|
||||
|
||||
FakeOidcProvider() throws Exception {
|
||||
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
||||
gen.initialize(2048);
|
||||
KeyPair kp = gen.generateKeyPair();
|
||||
this.rsaKey = new RSAKey.Builder((RSAPublicKey) kp.getPublic())
|
||||
.privateKey((RSAPrivateKey) kp.getPrivate())
|
||||
.keyUse(KeyUse.SIGNATURE)
|
||||
.algorithm(JWSAlgorithm.RS256)
|
||||
.keyID(UUID.randomUUID().toString())
|
||||
.build();
|
||||
|
||||
this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
this.issuer = "http://127.0.0.1:" + server.getAddress().getPort();
|
||||
|
||||
server.createContext("/.well-known/openid-configuration", ex -> respond(ex, discoveryDocument()));
|
||||
server.createContext("/jwks", ex -> respond(ex, new JWKSet(rsaKey.toPublicJWK()).toJSONObject().toString()));
|
||||
server.setExecutor(null);
|
||||
server.start();
|
||||
}
|
||||
|
||||
String issuer() { return issuer; }
|
||||
|
||||
/** Mints a valid RS256 access token — bearer-validation only, no full authorization-code round-trip needed. */
|
||||
String signToken(String subject, String audience) {
|
||||
return signToken(subject, audience, null, NO_ROLES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link #signToken(String, String)}, plus a {@code scope} claim (space-delimited,
|
||||
* matching {@link dev.relism.flash.ext.auth.Claims#hasScope}'s default claim path) and a
|
||||
* Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default
|
||||
* {@code rolesClaimPath}) when {@code roles} is non-empty.
|
||||
*/
|
||||
String signToken(String subject, String audience, String scope, String... roles) {
|
||||
try {
|
||||
JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
|
||||
.issuer(issuer)
|
||||
.subject(subject)
|
||||
.audience(audience)
|
||||
.issueTime(Date.from(Instant.now()))
|
||||
.expirationTime(Date.from(Instant.now().plusSeconds(300)));
|
||||
if (scope != null) builder.claim("scope", scope);
|
||||
if (roles.length > 0) builder.claim("realm_access", Map.of("roles", List.of(roles)));
|
||||
SignedJWT jwt = new SignedJWT(
|
||||
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), builder.build());
|
||||
jwt.sign(new RSASSASigner(rsaKey));
|
||||
return jwt.serialize();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static final String[] NO_ROLES = new String[0];
|
||||
|
||||
private String discoveryDocument() {
|
||||
return "{"
|
||||
+ "\"issuer\":\"" + issuer + "\","
|
||||
+ "\"authorization_endpoint\":\"" + issuer + "/auth\","
|
||||
+ "\"token_endpoint\":\"" + issuer + "/token\","
|
||||
+ "\"jwks_uri\":\"" + issuer + "/jwks\""
|
||||
+ "}";
|
||||
}
|
||||
|
||||
private static void respond(com.sun.net.httpserver.HttpExchange ex, String body) throws java.io.IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
ex.getResponseHeaders().add("Content-Type", "application/json");
|
||||
ex.sendResponseHeaders(200, bytes.length);
|
||||
try (OutputStream os = ex.getResponseBody()) { os.write(bytes); }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() { server.stop(0); }
|
||||
}
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.ext.oidc.OidcConfig;
|
||||
import dev.relism.flash.ext.oidc.OidcExtension;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.testing.FlashResponse;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} — see
|
||||
* {@link McpOidcIntegration#compileToolPolicy}. Same real-discovery/real-JWKS/real-RS256-token
|
||||
* approach as {@link McpExtensionSecurityTest}, against {@code fixtures.secured}'s tools.
|
||||
*/
|
||||
class McpAuthPolicyTest {
|
||||
|
||||
private static final String SECURED_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.secured";
|
||||
private static final String AUTHENTICATED_ONLY_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.authenticatedonly";
|
||||
|
||||
private static final FakeOidcProvider provider = newProvider();
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest secured = FlashTest.of(app -> {
|
||||
app.install(new OidcExtension(OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||
.toolsPackage(SECURED_TOOLS)
|
||||
.security(McpSecurity.REQUIRED)
|
||||
.build()));
|
||||
});
|
||||
|
||||
/** Tokens are audience-bound to this server, so the port has to be read back after boot. */
|
||||
private static String resourceId() {
|
||||
return "http://127.0.0.1:" + secured.port() + "/mcp";
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void closeProvider() {
|
||||
provider.close();
|
||||
}
|
||||
|
||||
// ── Tool policy ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception {
|
||||
callTool("admin_only", provider.signToken("user-1", resourceId(), null))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":true")
|
||||
.expectBodyContains("missing required role");
|
||||
|
||||
callTool("admin_only", provider.signToken("user-1", resourceId(), null, "admin"))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":false")
|
||||
.expectBodyContains("ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception {
|
||||
callTool("write_only", provider.signToken("user-1", resourceId(), "read"))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":true")
|
||||
.expectBodyContains("missing required scope");
|
||||
|
||||
callTool("write_only", provider.signToken("user-1", resourceId(), "read write"))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":false")
|
||||
.expectBodyContains("written");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception {
|
||||
callTool("open", provider.signToken("user-1", resourceId(), null))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":false")
|
||||
.expectBodyContains("open");
|
||||
}
|
||||
|
||||
private static FlashResponse callTool(String toolName, String token) {
|
||||
return secured.request()
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\""
|
||||
+ toolName + "\"}}")
|
||||
.post("/mcp");
|
||||
}
|
||||
|
||||
// ── Boot-time rejection ──────────────────────────────────────────────────
|
||||
// These assert that start() throws, so they build the app directly rather than through
|
||||
// FlashTest — a harness whose job is to boot an app is the wrong tool for asserting that
|
||||
// booting fails. Port 0 still removes the old free-port dance.
|
||||
|
||||
private FlashApp bootFailure;
|
||||
|
||||
@AfterEach
|
||||
void releaseBootFailureListener() {
|
||||
if (bootFailure != null) bootFailure.stop().join();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolAnnotated_butSecurityNone_failsAtBoot() {
|
||||
bootFailure = mcpApp(SECURED_TOOLS, McpSecurity.NONE);
|
||||
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start);
|
||||
assertTrue(error.getMessage().contains("no active OAuth2 protection"), error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bareAuthenticated_hasNoEffect_failsAtBoot() {
|
||||
bootFailure = mcpApp(AUTHENTICATED_ONLY_TOOLS, McpSecurity.REQUIRED);
|
||||
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start);
|
||||
assertTrue(error.getMessage().contains("no effect"), error.getMessage());
|
||||
}
|
||||
|
||||
private static FlashApp mcpApp(String toolsPackage, McpSecurity security) {
|
||||
FlashApp app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
|
||||
app.install(new OidcExtension(OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||
.toolsPackage(toolsPackage)
|
||||
.security(security)
|
||||
.build()));
|
||||
return app;
|
||||
}
|
||||
|
||||
private static FakeOidcProvider newProvider() {
|
||||
try {
|
||||
return new FakeOidcProvider();
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
-179
@@ -1,179 +0,0 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.ext.oidc.OidcConfig;
|
||||
import dev.relism.flash.ext.oidc.OidcExtension;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashApplication;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.testing.FlashRequest;
|
||||
import dev.relism.flash.testing.FlashResponse;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-auth-oidc}
|
||||
* installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
|
||||
* tokens — plus the fail-fast/degrade behavior when oidc is absent.
|
||||
*
|
||||
* <p>Four server configurations differ only in how MCP security is declared, so each gets its
|
||||
* own {@link FlashTest} and they share one provider.
|
||||
*/
|
||||
class McpExtensionSecurityTest {
|
||||
|
||||
private static final String TOOLS_PACKAGE = "dev.relism.flash.ext.mcp.fixtures";
|
||||
private static final String EXPLICIT_RESOURCE_ID = "https://mcp.example.com/mcp";
|
||||
|
||||
private static final FakeOidcProvider provider = newProvider();
|
||||
|
||||
/** MCP asked for AUTO security with no oidc installed — should degrade to public. */
|
||||
@RegisterExtension
|
||||
static FlashTest degraded = FlashTest.of(app -> app.install(new McpExtension(
|
||||
McpConfig.builder("auto-server")
|
||||
.toolsPackage(TOOLS_PACKAGE)
|
||||
.security(McpSecurity.AUTO)
|
||||
.build())));
|
||||
|
||||
/** REQUIRED with oidc, resource identifier derived from the request. */
|
||||
@RegisterExtension
|
||||
static FlashTest secured = FlashTest.of(securedApp(null, null));
|
||||
|
||||
/** REQUIRED with oidc and an explicitly declared resource identifier. */
|
||||
@RegisterExtension
|
||||
static FlashTest securedWithResourceId = FlashTest.of(securedApp(EXPLICIT_RESOURCE_ID, null));
|
||||
|
||||
/** REQUIRED with oidc and advertised scopes. */
|
||||
@RegisterExtension
|
||||
static FlashTest securedWithScopes =
|
||||
FlashTest.of(securedApp(null, new String[] {"openid", "profile", "email"}));
|
||||
|
||||
@AfterAll
|
||||
static void closeProvider() {
|
||||
provider.close();
|
||||
}
|
||||
|
||||
// ── No oidc installed ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void required_withoutOidc_throwsAtBoot() {
|
||||
// Asserting that boot fails, so this one builds its app directly rather than through
|
||||
// the harness; port(0) still removes the old free-port dance.
|
||||
FlashApp app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
|
||||
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||
.toolsPackage(TOOLS_PACKAGE)
|
||||
.security(McpSecurity.REQUIRED)
|
||||
.build()));
|
||||
try {
|
||||
assertThrows(IllegalStateException.class, app::start);
|
||||
} finally {
|
||||
app.stop().join();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void auto_withoutOidc_degradesToPublic() {
|
||||
post(degraded, initializeBody(), null).expectStatus(200);
|
||||
}
|
||||
|
||||
// ── REQUIRED with oidc ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void required_withOidc_rejectsMissingToken() {
|
||||
post(secured, initializeBody(), null).expectStatus(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_rejectsWrongAudience() throws Exception {
|
||||
String token = provider.signToken("user-1", "https://someone-else.example.com/resource");
|
||||
|
||||
post(securedWithResourceId, initializeBody(), token).expectStatus(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_acceptsValidAudience() throws Exception {
|
||||
String token = provider.signToken("user-1", EXPLICIT_RESOURCE_ID);
|
||||
|
||||
post(securedWithResourceId, initializeBody(), token)
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"protocolVersion\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception {
|
||||
String derivedResourceId = "http://127.0.0.1:" + secured.port() + "/mcp";
|
||||
|
||||
post(secured, initializeBody(), provider.signToken("user-1", derivedResourceId))
|
||||
.expectStatus(200);
|
||||
post(secured, initializeBody(), provider.signToken("user-1", "https://someone-else.example.com/resource"))
|
||||
.expectStatus(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_missingToken_challengeIncludesResourceMetadata() {
|
||||
FlashResponse response = post(secured, initializeBody(), null).expectStatus(401);
|
||||
|
||||
String challenge = response.header("WWW-Authenticate");
|
||||
assertTrue(challenge != null && challenge.contains("resource_metadata=\"http://127.0.0.1:"
|
||||
+ secured.port() + "/.well-known/oauth-protected-resource/mcp\""),
|
||||
"WWW-Authenticate: " + challenge);
|
||||
}
|
||||
|
||||
// ── Protected resource metadata ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() {
|
||||
FlashResponse response = secured.get("/.well-known/oauth-protected-resource/mcp")
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"resource\":\"http://127.0.0.1:" + secured.port() + "/mcp\"")
|
||||
.expectBodyContains("\"authorization_servers\":[\"" + provider.issuer() + "\"]");
|
||||
|
||||
assertTrue(!response.body().contains("scopes_supported"),
|
||||
"scopes_supported must be omitted when unset: " + response.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopesSupported_published_inProtectedResourceMetadata() {
|
||||
securedWithScopes.get("/.well-known/oauth-protected-resource/mcp")
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]");
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static FlashApplication securedApp(String resourceIdentifier, String[] scopesSupported) {
|
||||
return app -> {
|
||||
app.install(new OidcExtension(OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||
|
||||
McpConfig.Builder mcp = McpConfig.builder("secure-server")
|
||||
.toolsPackage(TOOLS_PACKAGE)
|
||||
.security(McpSecurity.REQUIRED);
|
||||
if (resourceIdentifier != null) mcp.resourceIdentifier(resourceIdentifier);
|
||||
if (scopesSupported != null) mcp.scopesSupported(scopesSupported);
|
||||
app.install(new McpExtension(mcp.build()));
|
||||
};
|
||||
}
|
||||
|
||||
private static FlashResponse post(FlashTest server, String body, String bearerToken) {
|
||||
FlashRequest request = server.request().header("Accept", "application/json").json(body);
|
||||
if (bearerToken != null) request.header("Authorization", "Bearer " + bearerToken);
|
||||
return request.post("/mcp");
|
||||
}
|
||||
|
||||
private static String initializeBody() {
|
||||
return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}";
|
||||
}
|
||||
|
||||
private static FakeOidcProvider newProvider() {
|
||||
try {
|
||||
return new FakeOidcProvider();
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -16,7 +16,7 @@ class McpRegistryTest {
|
||||
|
||||
@Test
|
||||
void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception {
|
||||
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), false, "realm_access.roles");
|
||||
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), null);
|
||||
|
||||
assertTrue(registry.hasTools());
|
||||
assertTrue(registry.hasResources());
|
||||
@@ -47,7 +47,7 @@ class McpRegistryTest {
|
||||
@Test
|
||||
void scan_emptyPackage_throwsInitializationException() {
|
||||
assertThrows(InitializationException.class,
|
||||
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), false, "realm_access.roles"));
|
||||
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), null));
|
||||
}
|
||||
|
||||
private static JsonNode findByField(JsonNode array, String field, String value) {
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.ext.security.SecurityExtension;
|
||||
import dev.relism.flash.ext.security.apikey.ApiKey;
|
||||
import dev.relism.flash.ext.security.apikey.ApiKeyExtension;
|
||||
import dev.relism.flash.ext.security.apikey.GeneratedApiKey;
|
||||
import dev.relism.flash.ext.security.oidc.OidcExtension;
|
||||
import dev.relism.flash.ext.security.oidc.OidcProvider;
|
||||
import dev.relism.flash.ext.security.test.FakeOidcProvider;
|
||||
import dev.relism.flash.testing.FlashRequest;
|
||||
import dev.relism.flash.testing.FlashResponse;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class McpSecurityTest {
|
||||
|
||||
static final FakeOidcProvider provider = start();
|
||||
static final GeneratedApiKey KEY = new ApiKeyExtension<String>("mk", id -> null).generate();
|
||||
static final ApiKeyExtension<String> apiKeys = new ApiKeyExtension<>("mk", id -> id.equals(KEY.id()) ? new ApiKey<>(KEY.id(), KEY.secretHash(), "agent", null, null) : null);
|
||||
|
||||
@RegisterExtension
|
||||
static final FlashTest app = FlashTest.of(flash -> flash
|
||||
.install(new SecurityExtension().roles((identity, role, on) -> identity.principal().name().equals(role + "@" + on.get("project"))))
|
||||
.install(new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret")))
|
||||
.install(apiKeys)
|
||||
.install(new McpExtension(McpConfig.builder("secure").toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured")
|
||||
.scopesSupported("openid", "email").build())));
|
||||
|
||||
/** The same chain with the RFC 8707 check turned off, for an authorization server that cannot mint a resource audience. */
|
||||
@RegisterExtension
|
||||
static final FlashTest relaxed = FlashTest.of(flash -> flash
|
||||
.install(new SecurityExtension().roles((identity, role, on) -> false))
|
||||
.install(new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret")))
|
||||
.install(new McpExtension(McpConfig.builder("relaxed").toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured")
|
||||
.requireTokenAudience(false).build())));
|
||||
|
||||
static FakeOidcProvider start() {
|
||||
try {
|
||||
return new FakeOidcProvider();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
static String resource() {
|
||||
return "http://127.0.0.1:" + app.port() + "/mcp";
|
||||
}
|
||||
|
||||
static FlashResponse call(Consumer<FlashRequest> credential, String method, String params) {
|
||||
return app.request().with(credential).header("Accept", "application/json")
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"" + method + "\",\"params\":" + params + "}")
|
||||
.post("/mcp");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAnonymousCallIsChallengedWithTheResourceMetadata() {
|
||||
call(request -> {}, "initialize", "{}").expectStatus(401)
|
||||
.expectHeader("WWW-Authenticate", "Bearer resource_metadata=\"http://127.0.0.1:" + app.port() + "/.well-known/oauth-protected-resource/mcp\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theProtectedResourceMetadataNamesTheIssuer() {
|
||||
app.get("/.well-known/oauth-protected-resource/mcp").expectStatus(200)
|
||||
.expectBody("{\"resource\":\"" + resource() + "\",\"authorization_servers\":[\"" + provider.issuer() + "\"],\"scopes_supported\":[\"openid\",\"email\"]}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTokenIsAcceptedOnlyForThisResource() {
|
||||
call(provider.bearer("u", Map.of("aud", resource())), "initialize", "{}").expectStatus(200).expectBodyContains("protocolVersion");
|
||||
call(provider.bearer("u", Map.of("aud", "https://elsewhere.example/mcp")), "initialize", "{}").expectStatus(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTokenWithoutTheResourceAudienceIsAcceptedWhenTheCheckIsOff() {
|
||||
relaxed.request().with(provider.bearer("u", Map.of("aud", "https://elsewhere.example/mcp"))).header("Accept", "application/json")
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
|
||||
.post("/mcp").expectStatus(200).expectBodyContains("protocolVersion");
|
||||
}
|
||||
|
||||
/** An API key is not audience-bound: the same chain authenticates agents that never saw an authorization server. */
|
||||
@Test
|
||||
void anApiKeyIsAcceptedBesideOAuth() {
|
||||
call(request -> request.header("Authorization", "Bearer " + KEY.token()), "initialize", "{}").expectStatus(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolPoliciesReadTheirTargetFromTheArguments() {
|
||||
Consumer<FlashRequest> admin = provider.bearer("admin@42", Map.of("aud", resource()));
|
||||
call(admin, "tools/call", "{\"name\":\"admin_only\",\"arguments\":{\"project\":\"42\"}}").expectBodyContains("\"isError\":false");
|
||||
call(admin, "tools/call", "{\"name\":\"admin_only\",\"arguments\":{\"project\":\"7\"}}").expectBodyContains("denied: missing role");
|
||||
call(provider.bearer("u", Map.of("aud", resource(), "scope", "write")), "tools/call", "{\"name\":\"write_only\"}").expectBodyContains("written");
|
||||
call(provider.bearer("u", Map.of("aud", resource())), "tools/call", "{\"name\":\"write_only\"}").expectBodyContains("denied: missing scope");
|
||||
}
|
||||
|
||||
@Test
|
||||
void securityIsRequiredUnlessDeclaredOff() {
|
||||
FlashTest unsecured = FlashTest.of(flash -> flash.install(new McpExtension(McpConfig.builder("x").toolsPackage("dev.relism.flash.ext.mcp.fixtures").build())));
|
||||
assertThrows(Exception.class, () -> unsecured.get("/mcp"));
|
||||
FlashTest contradictory = FlashTest.of(flash -> flash.install(new McpExtension(McpConfig.builder("x")
|
||||
.toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured").security(McpSecurity.NONE).build())));
|
||||
assertThrows(Exception.class, () -> contradictory.get("/mcp"));
|
||||
}
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
package dev.relism.flash.ext.mcp.authfixtures.authenticatedonly;
|
||||
|
||||
import dev.relism.flash.ext.mcp.McpTool;
|
||||
import dev.relism.flash.ext.mcp.TextContent;
|
||||
import dev.relism.flash.ext.mcp.Tool;
|
||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||
import dev.relism.flash.ext.auth.Authenticated;
|
||||
|
||||
/** Deliberately misconfigured fixture: bare @Authenticated has no effect on an McpTool — see
|
||||
* McpOidcIntegration#compileToolPolicy. Boot must fail with a clear message, not silently no-op. */
|
||||
@Tool(name = "pointless", description = "Exists only to prove @Authenticated alone fails boot")
|
||||
@Authenticated
|
||||
public class PointlessAuthTool extends McpTool {
|
||||
|
||||
@Override
|
||||
public ToolResponse call(ToolArguments args) {
|
||||
return ToolResponse.success(new TextContent("unreachable"));
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -5,10 +5,10 @@ import dev.relism.flash.ext.mcp.TextContent;
|
||||
import dev.relism.flash.ext.mcp.Tool;
|
||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||
import dev.relism.flash.ext.auth.RolesAllowed;
|
||||
import dev.relism.flash.ext.security.RolesAllowed;
|
||||
|
||||
@Tool(name = "admin_only", description = "Only callable with the admin role")
|
||||
@RolesAllowed("admin")
|
||||
@RolesAllowed(value = "admin", on = "project")
|
||||
public class AdminOnlyTool extends McpTool {
|
||||
|
||||
@Override
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import dev.relism.flash.ext.mcp.TextContent;
|
||||
import dev.relism.flash.ext.mcp.Tool;
|
||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||
import dev.relism.flash.ext.auth.ScopesAllowed;
|
||||
import dev.relism.flash.ext.security.ScopesAllowed;
|
||||
|
||||
@Tool(name = "write_only", description = "Only callable with the write scope")
|
||||
@ScopesAllowed("write")
|
||||
|
||||
Reference in New Issue
Block a user