diff --git a/.idea/encodings.xml b/.idea/encodings.xml
index aa0097f..278fb63 100644
--- a/.idea/encodings.xml
+++ b/.idea/encodings.xml
@@ -17,8 +17,8 @@
-
-
+
+
diff --git a/README.md b/README.md
index 10ac1e4..bb94243 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,12 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
-| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
-| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-oidc |
+| `flash-extensions/flash-ext-security-core` | Security: authentication chain, annotations, sessions, OpenAPI |
+| `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
+| `flash-extensions/flash-ext-security-apikey` | API keys |
+| `flash-extensions/flash-ext-security-form` | Password sign-in |
+| `flash-extensions/flash-ext-security-test` | Test identities, fake OpenID Provider |
+| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, secured by flash-ext-security-core |
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
@@ -146,7 +150,11 @@ FlashApp.create(8080)
See extension-specific READMEs for full details:
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
-- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md)
+- [`flash-ext-security-core`](flash-extensions/flash-ext-security-core/docs/README.md)
+- [`flash-ext-security-oidc`](flash-extensions/flash-ext-security-oidc/docs/README.md)
+- [`flash-ext-security-apikey`](flash-extensions/flash-ext-security-apikey/docs/README.md)
+- [`flash-ext-security-form`](flash-extensions/flash-ext-security-form/docs/README.md)
+- [`flash-ext-security-test`](flash-extensions/flash-ext-security-test/docs/README.md)
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
diff --git a/flash-extensions/flash-ext-limiter/docs/README.md b/flash-extensions/flash-ext-limiter/docs/README.md
index 098b0d8..8f1568a 100644
--- a/flash-extensions/flash-ext-limiter/docs/README.md
+++ b/flash-extensions/flash-ext-limiter/docs/README.md
@@ -36,7 +36,7 @@ FlashApp.create(8080)
// With custom resolvers
LimiterConfig conf = new LimiterConfig()
.registerResolver("auth_user", req ->
- ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
+ SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous");
FlashApp.create(8080)
.install(new LimiterExtension(conf))
diff --git a/flash-extensions/flash-ext-limiter/docs/key-resolvers.md b/flash-extensions/flash-ext-limiter/docs/key-resolvers.md
index 1b39d34..137d2cf 100644
--- a/flash-extensions/flash-ext-limiter/docs/key-resolvers.md
+++ b/flash-extensions/flash-ext-limiter/docs/key-resolvers.md
@@ -35,11 +35,11 @@ conf.registerResolver("ip", req -> {
LimiterConfig conf = new LimiterConfig();
```
-### By authenticated user (OIDC / ClaimsHolder)
+### By authenticated user
```java
conf.registerResolver("auth_user", req ->
- ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
+ SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous");
```
Requests from unauthenticated users share the `"anonymous"` bucket. If you want
@@ -88,7 +88,7 @@ returns the same key for the same user regardless of endpoint; the limit is set
```java
conf.registerResolver("auth_user", req ->
- ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anon");
+ SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anon");
```
```java
diff --git a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java
index 7fd678a..f8848e9 100644
--- a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java
+++ b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java
@@ -11,7 +11,7 @@ import dev.relism.flash.models.Request;
*
*
{@code
* conf.registerResolver("ip", req -> req.header("X-Forwarded-For"));
- * conf.registerResolver("auth_user", req -> ClaimsHolder.user().sub());
+ * conf.registerResolver("auth_user", req -> SecurityIdentity.current().principal().name());
* }
*/
@FunctionalInterface
diff --git a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java
index 607db07..b6b93df 100644
--- a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java
+++ b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java
@@ -21,8 +21,8 @@ import java.util.Map;
* {@code
* LimiterConfig conf = new LimiterConfig()
* .registerResolver("auth_user", req -> {
- * // custom logic — e.g. extract sub from ClaimsHolder
- * return ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous";
+ * // custom logic — e.g. key by the authenticated caller
+ * return SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous";
* });
*
* app.install(new LimiterExtension(conf));
diff --git a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java
index 9f09e85..7e5c5b2 100644
--- a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java
+++ b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java
@@ -43,7 +43,7 @@ import java.util.Map;
* Lambda routes (via Guard)
* {@code
* app.install(new LimiterExtension(
- * new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
+ * new LimiterConfig().registerResolver("auth_user", req -> SecurityIdentity.current().principal().name())));
*
* // inside a FlashContext.onReady(...) callback:
* Guard guard = ctx.require(Guard.class);
diff --git a/flash-extensions/flash-ext-mcp/docs/README.md b/flash-extensions/flash-ext-mcp/docs/README.md
index 643ce0b..5d9ee7f 100644
--- a/flash-extensions/flash-ext-mcp/docs/README.md
+++ b/flash-extensions/flash-ext-mcp/docs/README.md
@@ -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-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-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`
diff --git a/flash-extensions/flash-ext-mcp/docs/jackson-interop.md b/flash-extensions/flash-ext-mcp/docs/jackson-interop.md
index dddee59..19873c4 100644
--- a/flash-extensions/flash-ext-mcp/docs/jackson-interop.md
+++ b/flash-extensions/flash-ext-mcp/docs/jackson-interop.md
@@ -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-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)` 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.
diff --git a/flash-extensions/flash-ext-mcp/docs/keycloak.md b/flash-extensions/flash-ext-mcp/docs/keycloak.md
deleted file mode 100644
index 6326541..0000000
--- a/flash-extensions/flash-ext-mcp/docs/keycloak.md
+++ /dev/null
@@ -1,107 +0,0 @@
-# Keycloak cookbook
-
-`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any
-`flash-ext-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= does not include expected
-resource identifier "" — ...
-```
-
-`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`.
diff --git a/flash-extensions/flash-ext-mcp/docs/security.md b/flash-extensions/flash-ext-mcp/docs/security.md
index 43e3903..917fc94 100644
--- a/flash-extensions/flash-ext-mcp/docs/security.md
+++ b/flash-extensions/flash-ext-mcp/docs/security.md
@@ -1,170 +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-oidc` being
-installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExtension.routes()`:
+## OAuth2 protected resource
-| Policy | `flash-ext-oidc` installed | `flash-ext-oidc` absent |
-|---|---|---|
-| `REQUIRED` | protected | **boot fails** (`IllegalStateException`) |
-| `AUTO` (default) | protected | runs unprotected, logs a warning |
-| `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected |
+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:
-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.
+- `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.
-## Why `flash-ext-oidc` is an *optional* Maven dependency, concretely
+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.
-Maven's `true` only affects **transitive** propagation: consumers of
-`flash-ext-mcp` don't get `flash-ext-oidc` pulled in automatically unless they add it themselves.
-Within `flash-ext-mcp` itself, `flash-ext-oidc`'s classes are on the compile/test classpath as
-normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in
-source.
+`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.
-That reference is isolated in its own class, `McpOidcIntegration`, invoked only from inside a
-`catch (NoClassDefFoundError)` block. A bare class-literal like `OidcMiddleware.class` (which
-`ctx.find(OidcMiddleware.class)` needs) forces the JVM to resolve that type the moment it's
-evaluated — if `flash-ext-oidc` is not on the *runtime* classpath at all (a genuinely
-MCP-only install, no OAuth2 anywhere in the app), the first such reference throws
-`NoClassDefFoundError`. Keeping that reference inside a separate, lazily-loaded class means
-`McpExtension` itself loads and works fine standalone; only the attempt to actually use OIDC
-fails, and only when there's something to fail. This mirrors `OidcExtension`'s own lazy bridge to
-`flash-ext-openapi` — same technique, same reason.
+## Tool policies
-## 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-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-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
+The core annotations work on tools as on handlers, checked per `tools/call` against the caller the route
+authenticated:
```java
-@Tool(name = "delete_route", description = "Delete a route")
-@RolesAllowed("admin")
-public class DeleteRouteTool extends McpTool {
- @Override public ToolResponse call(ToolArguments args) { ... }
-}
+@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 { … }
```
-This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
-the thing that makes these annotations work on a `RequestHandler`) — it can't: every tool shares
-one HTTP route (`POST {rootPath}`), already wrapped by whatever `McpSecurity` resolved above, so
-there is no per-tool route to attach a different middleware chain to. Instead,
-`McpOidcIntegration.compileToolPolicy` reads the annotations once at boot (`McpRegistry.scan`)
-and compiles them into a closure (`McpAuthPolicy`) that `McpDispatcher` runs *after* the
-route-wide auth has already succeeded and *before* invoking the specific tool named in the
-`tools/call` request — narrowing what's already-authenticated, not replacing it. A denial is a
-normal `isError: true` tool result (see `ToolResponse.error`), not an HTTP-level rejection — the
-model sees why, the same as any other tool failure.
+A denial is a tool result with `isError: true` — the call reached the server, the tool did not run.
-Roles are read via `OidcUser#hasRole` against `McpConfig.rolesClaimPath(...)` (default
-`"realm_access.roles"`, matching `OidcConfig`'s own default — set this explicitly if the two
-diverge; there's no way to read `OidcConfig`'s actual configured value from here). Scopes use
-`OidcUser#hasScope`'s built-in default claim paths (`scope`/`scp`), no extra config needed.
-`@ScopesAllowed(match = ScopesAllowed.Match.ANY)` and multi-role `@RolesAllowed({"admin",
-"editor"})` (OR semantics) both work exactly as they do on a `RequestHandler`.
-
-**`@Authenticated` alone has no effect and fails boot.** Once oidc is active for a server, every
-tool call is already authenticated — there's no per-tool public/authenticated split the way
-there is for HTTP routes, so a bare `@Authenticated` on a tool can't mean anything and would
-silently do nothing if allowed to compile. Boot fails instead, with a message pointing at
-`@RolesAllowed`/`@ScopesAllowed` as the actual narrowing mechanism.
-
-**Annotating a tool without active OAuth2 also fails boot**, not silently at request time: if
-`@RolesAllowed`/`@ScopesAllowed`/`@Authenticated` shows up on a tool while `McpSecurity` resolved
-to unprotected (`NONE`, or `AUTO` with no oidc installed), that's very likely a forgotten
-`OidcExtension` install or a `McpSecurity.NONE` left over from local dev — `IllegalStateException`
-at `app.start()`.
-
-## The `HttpException` safety net
-
-`flash-ext-oidc`'s middleware throws `HttpException.unauthorized()`/`forbidden()` on auth
-failure. Flash5's core does **not** special-case `HttpException` in the default exception
-handler — the out-of-the-box `AbstractRouter` default always returns a generic `500`, regardless
-of the thrown exception's embedded status code; only an app that explicitly calls
-`FlashApp#onException(...)` (or installs something that does) gets `HttpException.status()`
-honored.
-
-To keep the MCP endpoint correct regardless of what the rest of the app configures,
-`McpTransportGuards.httpExceptionGuard()` wraps the whole route and translates `HttpException`
-into the right HTTP status itself, rather than letting it fall through to the app's (possibly
-unconfigured) global handler. This is scoped entirely to the MCP route — it does not touch or
-override the app's `onException` for any other route.
+`McpConfig.middleware(...)` runs after authentication, for rate limiting, auditing or tracing.
diff --git a/flash-extensions/flash-ext-mcp/pom.xml b/flash-extensions/flash-ext-mcp/pom.xml
index db7f5ac..314d91a 100644
--- a/flash-extensions/flash-ext-mcp/pom.xml
+++ b/flash-extensions/flash-ext-mcp/pom.xml
@@ -19,8 +19,7 @@
dev.relism
- flash-ext-oidc
- true
+ flash-ext-security-core
com.fasterxml.jackson.core
@@ -43,6 +42,22 @@
flash-testing
test
+
+ dev.relism
+ flash-ext-security-test
+
+
+ dev.relism
+ flash-ext-security-oidc
+ ${project.version}
+ test
+
+
+ dev.relism
+ flash-ext-security-apikey
+ ${project.version}
+ test
+
diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java
deleted file mode 100644
index 088d35e..0000000
--- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java
+++ /dev/null
@@ -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.
- *
- * {@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-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}.
- *
- *
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 check) {}
diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java
index a0538d7..d8268a7 100644
--- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java
+++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java
@@ -1,5 +1,7 @@
package dev.relism.flash.ext.mcp;
+import dev.relism.flash.routing.Middleware;
+
import java.util.ArrayList;
import java.util.List;
@@ -24,10 +26,10 @@ 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 allowedOrigins;
private final List scopesSupported;
+ private final List middleware;
private McpConfig(Builder b) {
this.name = b.name;
@@ -36,10 +38,10 @@ 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);
}
String name() { return name; }
@@ -48,10 +50,10 @@ 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 allowedOrigins() { return allowedOrigins; }
List scopesSupported() { return scopesSupported; }
+ List middleware() { return middleware; }
public static Builder builder(String name) { return new Builder(name); }
@@ -61,11 +63,11 @@ 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 allowedOrigins = new ArrayList<>();
private final List scopesSupported = new ArrayList<>();
+ private final List middleware = new ArrayList<>();
private Builder(String name) {
if (name == null || name.isBlank())
@@ -85,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-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-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
@@ -115,19 +105,15 @@ 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; }
+ /** 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;
+ }
+
public McpConfig build() {
if (toolsPackage == null || toolsPackage.isBlank())
throw new IllegalStateException(
diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java
index 86271d1..90fe905 100644
--- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java
+++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java
@@ -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) {
diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java
index baa0dff..82a1292 100644
--- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java
+++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java
@@ -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)}.
*
* {@code
- * // Standalone, no OAuth2
- * FlashApp.create(8080)
- * .install(new McpExtension(McpConfig.builder("my-mcp-server")
- * .toolsPackage("com.example.tools")
- * .build()))
- * .start();
- *
- * // With flash-ext-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()));
* }
*
- * 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.
+ *
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,68 +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 chain = new ArrayList<>(3);
- chain.add(McpTransportGuards.httpExceptionGuard());
- chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
- if (secured != null) chain.add(secured.security());
-
- 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-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-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-oidc is not installed and McpSecurity.AUTO degrades to unprotected. " +
- "Install flash-ext-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 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 chain) {
+ String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
+ chain.add(security.enforce(SecurityPolicy.AUTHENTICATED, (req, res) -> {
+ List 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 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 issuers(SecurityExtension security) {
+ return security.schemes().stream().map(SecurityScheme::issuer).filter(Objects::nonNull).toList();
+ }
}
diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java
index 426fc01..07636bd 100644
--- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java
+++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java
@@ -19,7 +19,7 @@ import java.nio.charset.StandardCharsets;
*
* 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-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.
*/
diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java
deleted file mode 100644
index 80eae76..0000000
--- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java
+++ /dev/null
@@ -1,180 +0,0 @@
-package dev.relism.flash.ext.mcp;
-
-import dev.relism.flash.ext.oidc.Authenticated;
-import dev.relism.flash.ext.oidc.ClaimsHolder;
-import dev.relism.flash.ext.oidc.OidcMiddleware;
-import dev.relism.flash.ext.oidc.OidcUser;
-import dev.relism.flash.ext.oidc.RolesAllowed;
-import dev.relism.flash.ext.oidc.ScopesAllowed;
-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-oidc}.
- *
- *
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-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.
- *
- *
Zero-config by design: when {@code flash-ext-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 OidcMiddleware}, 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 resourceIdentifier) {}
-
- /** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
- static Resolved resolve(FlashContext ctx, McpConfig config) {
- Optional oidc = ctx.find(OidcMiddleware.class);
- if (oidc.isEmpty()) return null;
-
- OidcMiddleware oidcMw = oidc.get();
- String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
- String issuer = config.authorizationServerIssuer() != null
- ? config.authorizationServerIssuer() : oidcMw.issuer();
- Function resourceId = req -> config.resourceIdentifier() != null
- ? config.resourceIdentifier()
- : OidcMiddleware.selfOrigin(req, oidcMw.selfScheme()) + config.rootPath();
-
- Middleware protect = oidcMw.protect(resourceMetadataPath);
- Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
- return new Resolved(secured, issuer, oidcMw.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 resourceIdentifier) {
- return next -> (req, res) -> {
- Map claims = ClaimsHolder.get();
- 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 OidcUser#hasRole}/{@link OidcUser#hasScope} already do.
- *
- * 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-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 check = () -> {
- OidcUser user = ClaimsHolder.user();
- 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(OidcUser user, String claimPath, String[] roles) {
- for (String role : roles) if (user.hasRole(claimPath, role)) return true;
- return false;
- }
-
- private static boolean hasScopes(OidcUser 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 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);
- }
-}
diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java
index 4615862..a109f6b 100644
--- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java
+++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java
@@ -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-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 instantiate(Class cls) {
try {
Constructor ctor = cls.getDeclaredConstructor();
diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java
index 55a7eb9..4fced75 100644
--- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java
+++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java
@@ -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 scopesSupported) {
+ /** {@code scopesSupported} is optional — omitted when empty. */
+ static String build(String resource, List authorizationServers, List 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");
diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java
index 211ea5d..b8eedab 100644
--- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java
+++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java
@@ -1,17 +1,11 @@
package dev.relism.flash.ext.mcp;
-/**
- * OAuth2 requirement policy for the MCP endpoint, resolved against whether
- * {@code flash-ext-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-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-oidc} is installed; otherwise run unprotected and log a warning. */
- AUTO,
-
- /** Never protect the endpoint, even if {@code flash-ext-oidc} is installed elsewhere in the app. */
+ /** A public endpoint. Tools declaring security annotations fail the boot. */
NONE
}
diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java
index 94ab5a0..c6447bf 100644
--- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java
+++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java
@@ -19,7 +19,7 @@ final class McpTransportGuards {
* allowed through — only a present but disallowed value is rejected.
*
* 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 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-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
diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java
deleted file mode 100644
index 8f0bc37..0000000
--- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java
+++ /dev/null
@@ -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-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.oidc.OidcUser#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); }
-}
diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java
deleted file mode 100644
index af42bdb..0000000
--- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java
+++ /dev/null
@@ -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);
- }
- }
-}
diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java
new file mode 100644
index 0000000..cf1f253
--- /dev/null
+++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java
@@ -0,0 +1,61 @@
+package dev.relism.flash.ext.mcp;
+
+import dev.relism.flash.exceptions.HttpException;
+import dev.relism.flash.testing.FlashTest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Application middleware on the MCP route. Until this existed a consumer had no way to put rate
+ * limiting, audit logging or tracing in front of {@code /mcp} — the chain was assembled entirely
+ * inside the extension.
+ */
+class McpConfigMiddlewareTest {
+
+ private static final AtomicInteger CALLS = new AtomicInteger();
+
+ @RegisterExtension
+ static FlashTest mcp = FlashTest.of(app -> app.install(new McpExtension(
+ McpConfig.builder("middleware-server")
+ .version("1.0.0")
+ .toolsPackage("dev.relism.flash.ext.mcp.fixtures")
+ .security(McpSecurity.NONE)
+ .middleware(
+ next -> (req, res) -> {
+ CALLS.incrementAndGet();
+ return next.handle(req, res);
+ },
+ next -> (req, res) -> {
+ if ("deny".equals(req.header("X-Test-Gate"))) throw HttpException.forbidden();
+ return next.handle(req, res);
+ })
+ .build())));
+
+ @Test
+ void appMiddlewareRunsOnTheMcpRoute() {
+ int before = CALLS.get();
+ mcp.request()
+ .json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
+ .post("/mcp")
+ .expectStatus(200);
+ assertEquals(before + 1, CALLS.get());
+ }
+
+ /**
+ * The point of the hook: with {@link McpSecurity#NONE}, an app's own guard is the only thing
+ * in front of the endpoint — which is how an app that does not authenticate with OAuth2
+ * protects {@code /mcp} at all.
+ */
+ @Test
+ void appMiddlewareCanRejectTheRequest() {
+ mcp.request()
+ .header("X-Test-Gate", "deny")
+ .json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
+ .post("/mcp")
+ .expectStatus(403);
+ }
+}
diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java
deleted file mode 100644
index 111b5ea..0000000
--- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java
+++ /dev/null
@@ -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-oidc}
- * installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
- * tokens — plus the fail-fast/degrade behavior when oidc is absent.
- *
- * 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);
- }
- }
-}
diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java
index a3faa29..349b84a 100644
--- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java
+++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java
@@ -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) {
diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpSecurityTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpSecurityTest.java
new file mode 100644
index 0000000..a107b1b
--- /dev/null
+++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpSecurityTest.java
@@ -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("mk", id -> null).generate();
+ static final ApiKeyExtension 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 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 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"));
+ }
+}
diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java
deleted file mode 100644
index 9d9a622..0000000
--- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java
+++ /dev/null
@@ -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.oidc.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"));
- }
-}
diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java
index 1bd96f5..ad3ef90 100644
--- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java
+++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java
@@ -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.oidc.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
diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java
index 14b390f..8526506 100644
--- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java
+++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java
@@ -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.oidc.ScopesAllowed;
+import dev.relism.flash.ext.security.ScopesAllowed;
@Tool(name = "write_only", description = "Only callable with the write scope")
@ScopesAllowed("write")
diff --git a/flash-extensions/flash-ext-oidc/README.md b/flash-extensions/flash-ext-oidc/README.md
deleted file mode 100644
index 8382dce..0000000
--- a/flash-extensions/flash-ext-oidc/README.md
+++ /dev/null
@@ -1,462 +0,0 @@
-# flash-ext-oidc
-
-Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
-Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
-
-Standards alignment focuses on OIDC Core + OAuth2 bearer APIs while preserving Flash's
-hot-path model (middleware compiled at mount time, no heavy runtime work).
-
-## What it provides
-
-| Component | Description |
-|---|---|
-| `GET {prefix}/login` | Starts the OIDC flow: builds the authorization URL with PKCE + state, redirects |
-| `GET {prefix}/callback` | Exchanges the code, validates the ID token, creates a session, redirects |
-| `POST {prefix}/logout` | Invalidates the session, redirects to the provider's `end_session_endpoint` |
-| `@Authenticated` | Annotation: protects a class-based handler (redirects browsers, 401 for API clients) |
-| `@RolesAllowed(...)` | Annotation: protects with role check (OR semantics) |
-| `@ScopesAllowed(...)` | Annotation: protects with scope check (`ALL` default, `ANY` optional) |
-| `OidcMiddleware` | Programmatic middleware for lambda routes |
-| `ClaimsHolder` / `OidcUser` | Thread-local user info accessible from any protected handler |
-| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
-
-## Dependencies
-
-```xml
-
- dev.relism
- flash-ext-oidc
- 1.0-SNAPSHOT
-
-```
-
-Transitive: `nimbus-jose-jwt`, `json-smart`.
-Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically.
-
-## Installation
-
-```java
-FlashApp.create(8080)
- .install(new JacksonExtension())
- .install(new OpenApiExtension(...)) // optional — enables Swagger security
- .install(new OidcExtension(
- OidcConfig.builder(
- "https://idp.example.com",
- "my-client", "my-secret", "/auth/callback")
- .build()
- ))
- .start();
-```
-
-Install order is irrelevant. The two-phase extension model guarantees all services
-(including `OpenApiSecurityRegistry` from `flash-ext-openapi`) are registered before
-any extension's routes phase runs.
-
-### Keycloak shortcut
-
-```java
-OidcConfig.keycloak(
- "https://keycloak.example.com", // server URL (no realm)
- "myrealm", // realm
- "my-client", "my-secret", // client credentials
- "/auth/callback") // redirect URI (server-relative)
- .https() // behind TLS
- .build()
-```
-
-`keycloak()` pre-sets `rolesClaimPath("realm_access.roles")` and constructs the issuer as
-`{serverUrl}/realms/{realm}`.
-
-### Authelia / generic IdP
-
-```java
-OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/callback")
- .rolesClaimPath("groups")
- .build()
-```
-
-## OidcConfig reference
-
-### Required fields
-
-| Field | Description |
-|---|---|
-| `issuer` | Provider base URL — also used for OIDC discovery |
-| `clientId` | OAuth2 client ID |
-| `clientSecret` | OAuth2 client secret |
-| `redirectUri` | Callback URI; server-relative paths (starting with `/`) are resolved at request time |
-
-### Builder options
-
-| Method | Default | Description |
-|---|---|---|
-| `.scopes("openid profile email")` | `"openid profile email"` | Space-separated requested scopes |
-| `.routePrefix("/auth")` | `"/auth"` | Prefix for login/callback/logout routes |
-| `.selfScheme("http")` | `"http"` | Scheme used when resolving server-relative redirect URIs |
-| `.https()` | — | Shorthand for `.selfScheme("https")` |
-| `.rolesClaimPath("realm_access.roles")` | `"realm_access.roles"` | Dot-path to the roles array in JWT claims |
-| `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes |
-| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation |
-| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout |
-| `.sessionStore(store)` | `InMemoryOidcSessionStore` | Custom session store (see below) |
-| `.clientAuthMethod(ClientAuthMethod.POST)` | `POST` | `POST` = credentials in body; `BASIC` = `Authorization: Basic` |
-| `.insecureTls()` | `false` | Disables TLS certificate verification — **development only** |
-| `.schemeName("myscheme")` | derived from issuer | OpenAPI security scheme name |
-
-### Environment variables (`OidcConfig.fromEnv()`)
-
-```
-OIDC_ISSUER required
-OIDC_CLIENT_ID required
-OIDC_CLIENT_SECRET required
-OIDC_REDIRECT_URI required e.g. /auth/callback
-OIDC_SCOPES default: openid profile email
-OIDC_ROUTE_PREFIX default: /auth
-OIDC_SELF_SCHEME default: http
-OIDC_ROLES_CLAIM default: realm_access.roles
-OIDC_SCOPE_CLAIMS default: scope,scp
-OIDC_ALGORITHM default: RS256
-OIDC_POST_LOGOUT_REDIRECT default: /
-OIDC_CLIENT_AUTH_METHOD default: POST
-```
-
-## Protecting routes
-
-### Class-based handlers (annotations)
-
-```java
-@Route(method = HttpMethod.GET, path = "/me")
-@Authenticated
-public class MePage extends JacksonHandler {
- @Override
- public Object handle(Request req, Response res) {
- OidcUser u = ClaimsHolder.user();
- return json(res, Map.of("sub", u.sub(), "email", u.email()));
- }
-}
-
-@Route(method = HttpMethod.GET, path = "/admin")
-@RolesAllowed("admin") // OR semantics: "admin" OR "superuser"
-// @RolesAllowed({"admin", "superuser"})
-public class AdminPage extends JacksonHandler { ... }
-
-@Route(method = HttpMethod.POST, path = "/orders")
-@ScopesAllowed("orders:write") // default = ALL semantics
-public class CreateOrder extends JacksonHandler { ... }
-
-@Route(method = HttpMethod.POST, path = "/payments")
-@ScopesAllowed(value = {"payments:write", "payments:admin"}, match = ScopesAllowed.Match.ANY)
-public class PayOrder extends JacksonHandler { ... }
-
-@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
-@RolesAllowed("admin")
-@ScopesAllowed("users:delete") // combined with AND semantics
-public class DeleteUser extends JacksonHandler { ... }
-```
-
-The middleware is injected automatically by the annotation processor — no manual wiring needed.
-
-Annotation composition rules:
-
-- `@Authenticated` requires auth only
-- `@RolesAllowed` implies authentication + role OR-check
-- `@ScopesAllowed` implies authentication + scope check (`ALL`/`ANY`)
-- combining `@RolesAllowed` + `@ScopesAllowed` uses AND semantics
-- `@Authenticated(optional = true)` cannot be combined with role/scope constraints
-
-### Lambda routes (manual middleware)
-
-For lambda routes, pass the middleware as a varargs argument. Retrieve `OidcMiddleware`
-from the context inside another extension's `routes()` phase, or after `start()`:
-
-```java
-OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
-
-// Authentication only
-app.get("/api/me", (req, res) -> {
- OidcUser u = ClaimsHolder.user(); // never null here
- return Map.of("sub", u.sub(), "email", u.email());
-}, oidc.protect());
-
-// Authentication + role check
-app.delete("/api/admin/users/{id}", (req, res) -> {
- OidcUser u = ClaimsHolder.user();
- // ...
-}, oidc.requireRole("admin"));
-
-// Multiple roles (OR): passes if user holds any one of them
-app.get("/api/reports", (req, res) -> { ... }, oidc.requireRole("admin", "reports-viewer"));
-
-// Require all listed scopes
-app.post("/api/orders", (req, res) -> { ... }, oidc.requireScopes("orders:write", "payments:write"));
-
-// Require at least one listed scope
-app.post("/api/payments", (req, res) -> { ... }, oidc.requireAnyScope("payments:write", "payments:admin"));
-```
-
-`oidc.protect()` / `oidc.requireRole(...)` / `oidc.requireScopes(...)` return a `Middleware` — a composable
-`Handler → Handler` wrapper. Flash applies middleware right-to-left so the OIDC check
-runs before your handler.
-
-## Accessing the authenticated user
-
-`ClaimsHolder` holds the JWT claims for the current request in a `ThreadLocal`.
-It is populated by the OIDC middleware before your handler runs and cleared in the
-`finally` block afterward. It is safe with virtual threads (each request gets its
-own virtual thread, so `ThreadLocal` values are naturally isolated).
-
-### OidcUser (preferred)
-
-```java
-OidcUser u = ClaimsHolder.user(); // never null inside a protected handler
-
-String sub = u.sub(); // unique user ID
-String email = u.email();
-String username = u.username(); // preferred_username
-String name = u.name(); // full display name
-
-// Roles — pass the dot-path matching your provider's claim structure
-List roles = u.roles("realm_access.roles"); // Keycloak realm roles
-List clientRoles = u.roles("resource_access.my-client.roles"); // Keycloak client roles
-List groups = u.roles("groups"); // Authelia
-
-boolean isAdmin = u.hasRole("realm_access.roles", "admin");
-
-// Scopes (OIDC/OAuth2 generic): checks "scope" then "scp"
-List scopes = u.scopes();
-boolean canWrite = u.hasScope("orders:write");
-
-// Custom claim path resolution (for provider-specific payloads)
-List customScopes = u.scopes("scope,scp,permissions.scopes");
-boolean canApprove = u.hasScope("permissions.scopes", "orders:approve");
-
-// Arbitrary claim
-String locale = (String) u.claim("locale");
-Long exp = u.claim("exp", Long.class);
-
-// Full raw map (escape hatch)
-Map all = u.claims();
-```
-
-### Raw access (escape hatch)
-
-```java
-Map claims = ClaimsHolder.get();
-String email = ClaimsHolder.claim("email");
-```
-
-## Performance
-
-The middleware adds negligible overhead on the hot path for authenticated requests:
-
-| Step | Cost |
-|---|---|
-| `Authorization` header check | `O(1)` map lookup |
-| Cookie parse | `O(cookie_length)` single pass scan |
-| Session lookup | `O(1)` `ConcurrentHashMap.get()` |
-| Token expiry check | `O(1)` `Instant` comparison |
-| `ClaimsHolder.set()` | `O(1)` `ThreadLocal.set()` |
-
-No network calls, no cryptography, no JSON parsing on the happy path (valid session).
-JWKS key fetching only happens for Bearer token validation and is cached + rate-limited by
-Nimbus's `JWKSourceBuilder`. Silent token refresh only triggers when the access token expires.
-
-Role/scope claim paths are compiled once during middleware construction (mount time), not per request.
-
-## Authentication flow details
-
-On each request the middleware resolves credentials in this order:
-
-1. **Bearer token** (`Authorization: Bearer `) — validated against JWKS.
-2. **Session cookie** (`oidc_session`) — looked up in the session store; transparently
- refreshed if the access token is expired (silent refresh via refresh token).
-3. **No valid credentials**:
- - Browser clients (no `Accept: application/json`) → redirect to `{prefix}/login?redirect={path}`
- - API clients → `401 Unauthorized`
-
-### API error semantics (RFC 6750)
-
-For API clients (`Accept: application/json`) the middleware includes `WWW-Authenticate`:
-
-- missing credentials: `Bearer realm=""`
-- invalid bearer token: `Bearer realm="", error="invalid_token"`
-- insufficient scopes: `Bearer realm="", error="insufficient_scope", scope=""`
-
-This enables interoperable client-side handling and proper OAuth2 challenge semantics.
-
-### Token validation (OIDC Core §3.1.3.7)
-
-| Check | Access token | ID token |
-|---|---|---|
-| Signature (JWKS) | yes | yes |
-| `iss` | yes | yes |
-| `aud` = clientId | no (varies by provider) | yes |
-| `exp`, `iat`, `sub` | yes | yes |
-| `nonce` | — | yes |
-
-JWKS keys are cached, rate-limited, and retried on cache-miss (handles key rotation).
-
-### Claim merge strategy
-
-At callback time the extension merges access token + ID token claims:
-
-- Access token claims first (contains provider-specific data like `realm_access.roles`)
-- ID token claims override (contains verified identity: `sub`, `email`, `name`, …)
-
-This is provider-agnostic: authorization claims live in the AT per RFC 9068,
-identity claims live in the IT per OIDC Core.
-
-## Standards & compliance notes
-
-This extension is designed to be compliant with the most relevant OIDC/OAuth2 RFCs:
-
-- RFC 8414 (Authorization Server Metadata): discovery via `/.well-known/openid-configuration`
-- OpenID Connect Core 1.0: Authorization Code flow + PKCE + `nonce` validation on ID token
-- RFC 7636 (PKCE): S256 challenge/verifier flow
-- RFC 6750 (Bearer Token Usage): `WWW-Authenticate` challenges with standard error codes
-- RFC 9068 (JWT Profile for Access Tokens): JWT bearer access-token validation path
-- RFC 7519 / RFC 7517 / RFC 7515 family: JWT/JWK/JWS validation via Nimbus + JWKS caching/rotation
-
-Provider interoperability details:
-
-- scope extraction supports both standard forms: `scope` (space-delimited string) and `scp` (list/string)
-- roles remain configurable via `rolesClaimPath` (`realm_access.roles`, `groups`, etc.)
-- scope claim fallback chain is configurable via `scopeClaimPaths`
-
-## Testing scopes with Keycloak
-
-Quick path to test `@ScopesAllowed` end-to-end:
-
-1. **Create a client scope**
- - Realm -> Client scopes -> Create
- - Name: `orders:write` (or any scope name you want to enforce)
-2. **Attach it to your client**
- - Clients -> `` -> Client scopes
- - Add the scope as `Default` (always in token) or `Optional` (requested via `scope` param)
-3. **Ensure scope mapper reaches the token**
- - For most Keycloak setups this is automatic via built-in `microprofile-jwt`/scope mappers
- - Verify the access token contains either `scope` string or `scp` list
-4. **Request the scope in Flash config**
- - Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"`
-5. **Protect a handler**
- - `@ScopesAllowed("orders:write")` on class-based handlers
- - or `oidc.requireScopes("orders:write")` for lambda routes
-6. **Verify behavior**
- - token with scope -> 200
- - token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope`
-
-Useful token inspection flow while testing:
-
-- Obtain a token from Keycloak
-- Decode payload (`jwt.io` or local tool)
-- check `scope` / `scp` claims
-- call your protected endpoint and inspect status + `WWW-Authenticate`
-
-## Session store
-
-The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments.
-For clustered deployments, implement `OidcSessionStore`:
-
-```java
-public interface OidcSessionStore {
- void save(OidcSession session);
- Optional find(String sessionId);
- void delete(String sessionId);
-}
-```
-
-```java
-OidcConfig.builder(...)
- .sessionStore(new RedisOidcSessionStore(redisClient))
- .build()
-```
-
-`OidcSession` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map).
-
-## Logout
-
-Add a logout button anywhere in your UI — a `
-```
-
-The `POST {prefix}/logout` handler:
-1. Reads the `oidc_session` cookie, looks up the session, retrieves the `id_token`.
-2. Deletes the local session and clears the cookie (`Max-Age=0`).
-3. If the provider has an `end_session_endpoint` (standard IdPs do), redirects there with
- `?id_token_hint=&post_logout_redirect_uri=` — this logs
- the user out of the IdP as well.
-4. Otherwise redirects to `postLogoutRedirectUri` (default: `/`).
-
-## Bearer token (API clients)
-
-For API-to-API or SPA-to-API calls, pass a Bearer access token directly. The middleware
-validates the JWT signature against JWKS and extracts the claims — no session involved:
-
-```
-Authorization: Bearer
-```
-
-The token must be a JWT (opaque tokens are not supported). Claims are available via
-`ClaimsHolder.user()` as usual.
-
-## Multi-tenant
-
-Multiple OIDC providers on one server — each `OidcExtension` instance is fully independent
-(its own PKCE state store, session store, validator, and middleware):
-
-```java
-OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", "clientA", "secretA", "/a/auth/callback")
- .routePrefix("/a/auth").schemeName("tenantA").build();
-
-OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", "clientB", "secretB", "/b/auth/callback")
- .routePrefix("/b/auth").schemeName("tenantB").build();
-
-app.install(new OidcExtension(tenantA))
- .install(new OidcExtension(tenantB));
-```
-
-To reference a specific tenant's middleware on lambda routes, keep the extension instances
-and retrieve `OidcMiddleware` from context after `start()`:
-
-```java
-OidcExtension extA = new OidcExtension(tenantA);
-OidcExtension extB = new OidcExtension(tenantB);
-
-FlashApp app = FlashApp.create(8080)
- .install(extA)
- .install(extB)
- .start()
- .join(); // wait for bind
-
-OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // last registered = tenantB
-```
-
-> **Note:** because both extensions register `OidcMiddleware.class` in the same context,
-> only the last one wins under that key. For multi-tenant setups, use distinct context
-> keys or provide middleware under a wrapper/alias type, or use lambda routes with explicit
-> middleware captured from the extension instance before `install()`.
-
-Class-based handlers annotated with `@Authenticated` / `@RolesAllowed` get the last
-registered processor's middleware. For true multi-tenant class-based routing, install
-tenant-specific annotation processors with different annotations.
-
-## OpenAPI integration
-
-If `flash-ext-openapi` is on the classpath and installed (order irrelevant),
-the extension automatically:
-
-- Adds a `components.securitySchemes` entry for the provider (OAuth2, authorizationCode flow)
-- Adds `security` requirements to every operation whose handler carries `@Authenticated`
- , `@RolesAllowed`, or `@ScopesAllowed`
-
-No extra code needed. To customize the scheme name:
-
-```java
-OidcConfig.builder(...).schemeName("keycloak").build()
-```
-
-If `flash-ext-openapi` is absent the integration is silently skipped.
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java
deleted file mode 100644
index 4888202..0000000
--- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java
+++ /dev/null
@@ -1,40 +0,0 @@
-package dev.relism.flash.ext.oidc;
-
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-
-/**
- * Marks a handler as requiring a valid JWT. Any bearer token that passes
- * signature + expiry + issuer validation is accepted — no role check is performed.
- *
- * For role-based access use {@link RolesAllowed} instead (it implies authentication).
- *
- *
Set {@code optional = true} on public routes that personalise their response when
- * the user happens to be logged in but should remain accessible to guests. The middleware
- * will populate {@link ClaimsHolder} if credentials are present and silently skip it
- * otherwise — the request is never rejected.
- *
- *
{@code
- * // Hard auth — redirects / 401 when unauthenticated:
- * @Route(method = HttpMethod.GET, path = "/api/profile")
- * @Authenticated
- * public class GetProfile extends JacksonHandler { ... }
- *
- * // Soft auth — guest-friendly, ClaimsHolder populated only when logged in:
- * @Route(method = HttpMethod.GET, path = "/")
- * @Authenticated(optional = true)
- * public class HomePage extends HtmlHandler { ... }
- * }
- */
-@Retention(RetentionPolicy.RUNTIME)
-@Target(ElementType.TYPE)
-public @interface Authenticated {
- /**
- * When {@code true} the middleware never rejects unauthenticated requests — it only
- * populates {@link ClaimsHolder} when valid credentials are present.
- * Defaults to {@code false} (hard authentication required).
- */
- boolean optional() default false;
-}
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java
deleted file mode 100644
index 490147a..0000000
--- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java
+++ /dev/null
@@ -1,71 +0,0 @@
-package dev.relism.flash.ext.oidc;
-
-import java.util.Map;
-
-/**
- * Thread-local store for JWT claims, populated by the OIDC middleware before
- * the handler runs and cleared in the {@code finally} block afterward.
- *
- * Safe with virtual threads: each request gets its own virtual thread, so
- * {@link ThreadLocal} values are naturally isolated per request.
- *
- *
{@code
- * // Inside any handler protected by @Authenticated or @RolesAllowed:
- *
- * // Preferred — typed wrapper:
- * OidcUser user = ClaimsHolder.user();
- * String email = user.email();
- * List roles = user.roles("realm_access.roles");
- *
- * // Raw escape hatch:
- * Map all = ClaimsHolder.get();
- * }
- */
-public final class ClaimsHolder {
-
- private static final ThreadLocal