diff --git a/README.md b/README.md
index 0fd4860..721c3eb 100644
--- a/README.md
+++ b/README.md
@@ -65,24 +65,24 @@ app.get("/users/{id}", (req, res) -> {
### Class-based handlers
-Extend `RequestHandler` (or a subclass like `JacksonHandler`) and annotate with `@Route`:
+Extend `RequestHandler`, annotate it, then scan its package. Dependencies are cached in
+`onInit()` after Flash has resolved its complete boot-time service graph:
```java
-@Route(method = HttpMethod.GET, path = "/api/users")
-public class ListUsers extends JacksonHandler {
- @Override
- public Object handle(Request req, Response res) throws Exception {
- return json(res, List.of("alice", "bob"));
- }
+@GET("/api/users")
+public class ListUsers extends RequestHandler {
+ private UserService users;
+
+ @Override protected void onInit() { users = require(UserService.class); }
+ @Override public Object handle(Request req, Response res) { return users.list(); }
}
-// Register:
-app.register(new ListUsers());
+app.scan("dev.example.api");
```
### Middleware
-Apply middleware via `.with()` on the `RouteHandle` returned by any registration call:
+Apply middleware at registration. Flash composes the final chain at boot:
```java
Middleware authCheck = next -> (req, res) -> {
@@ -91,14 +91,13 @@ Middleware authCheck = next -> (req, res) -> {
return next.handle(req, res);
};
-app.get("/secure", (req, res) -> "secret data")
- .with(authCheck);
+app.get("/secure", (req, res) -> "secret data", authCheck);
```
Multiple middlewares are composed outermost-first (left-to-right in the call):
```java
-app.get("/admin", handler).with(logging, auth, rateLimit);
+app.get("/admin", handler, logging, auth, rateLimit);
// execution order: logging → auth → rateLimit → handler
```
@@ -120,22 +119,22 @@ processors, services):
```java
app.mount("/api", scope -> {
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
- scope.register(new UserHandler()); // @Route(path="/users") → GET /api/users
scope.scan("dev.example.api");
});
```
## Extensions
-Extensions are installed before route registration. Each extension receives the `FlashRegistrar`
-and `FlashContext` — it can register routes, expose services, and register annotation processors.
+Extensions have one declarative `configure` method. They declare services, processors and route
+callbacks; Flash resolves the complete graph, materialises routes, compiles both routers, then
+opens listeners. Extension install order never makes a service “not ready”.
```java
FlashApp.create(8080)
.install(new JacksonExtension())
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
.install(new OidcExtension(oidcConfig))
- .register(new MyHandler())
+ .scan("dev.example.handlers")
.start();
```
diff --git a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java
index 60ad68d..63f6e72 100644
--- a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java
+++ b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java
@@ -4,9 +4,11 @@ import dev.relism.flash.ext.data.core.Tx;
import dev.relism.flash.ext.data.core.TxDefinition;
import dev.relism.flash.ext.data.core.TxManager;
import dev.relism.flash.ext.data.core.TransactionPropagation;
-import dev.relism.flash.extension.ExtensionPhase;
+import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
+import dev.relism.flash.routing.MiddlewareKey;
+import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.routing.Middleware;
import jakarta.transaction.Transactional;
@@ -14,6 +16,7 @@ import java.util.List;
import java.util.Objects;
public final class DataExtension implements FlashExtension {
+ private static final MiddlewareKey TRANSACTION = MiddlewareKey.of("flash.data.transaction");
private final TxManager txManager;
private final Tx tx;
@@ -23,7 +26,7 @@ public final class DataExtension implements FlashExtension {
}
@Override
- public void provide(FlashContext ctx) {
+ public void configure(FlashRegistrar> app, FlashContext ctx) {
ctx.provide(Tx.class, tx);
ctx.provide(TxManager.class, txManager);
ctx.addAnnotationProcessor(handlerClass -> {
@@ -36,15 +39,10 @@ public final class DataExtension implements FlashExtension {
Middleware middleware = next -> (req, res) -> {
return tx.call(definition, () -> next.handle(req, res));
};
- return List.of(middleware);
+ return List.of(MiddlewareNode.of(TRANSACTION, middleware));
});
}
- @Override
- public int priority() {
- return ExtensionPhase.EARLY.value;
- }
-
private TransactionPropagation mapTxType(Transactional.TxType txType) {
return switch (txType) {
case REQUIRED -> TransactionPropagation.REQUIRED;
diff --git a/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java b/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java
index 206f2f8..dc9a4c9 100644
--- a/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java
+++ b/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import dev.relism.flash.extension.FlashContext;
+import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.routing.Middleware;
@@ -12,7 +13,8 @@ import dev.relism.flash.routing.Middleware;
*
*
Exposes a {@link Json} utility instance in the {@link FlashContext} under
* {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)}
- * inside {@code onInit()} (class-based) or inside {@link FlashExtension#routes} (extensions).
+ * inside {@code onInit()} (class-based) or from a {@link FlashContext#onReady(Runnable)}
+ * callback (extensions).
*
*
The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
@@ -83,7 +85,7 @@ public class JacksonExtension implements FlashExtension {
}
@Override
- public void provide(FlashContext ctx) {
+ public void configure(FlashRegistrar> app, FlashContext ctx) {
Json json = new Json(mapper);
ctx.provide(Json.class, json);
ctx.provide(ObjectMapper.class, mapper);
diff --git a/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java b/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java
index 1518d08..aced4b8 100644
--- a/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java
+++ b/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java
@@ -19,12 +19,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class JacksonExtensionTest {
@Test
- void provide_registers_json_mapper_and_middleware() {
+ void configure_registers_json_mapper_and_middleware() {
FlashContext ctx = new FlashContext();
ObjectMapper mapper = new ObjectMapper();
JacksonExtension ext = new JacksonExtension(mapper);
- ext.provide(ctx);
+ ext.configure(null, ctx);
+ ctx.complete();
assertNotNull(ctx.require(Json.class));
assertNotNull(ctx.require(JacksonMiddleware.class));
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 7e2bed6..9f09e85 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
@@ -5,12 +5,13 @@ import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.extension.AnnotationProcessor;
-import dev.relism.flash.extension.ExtensionPhase;
+import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
-import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.routing.Middleware;
+import dev.relism.flash.routing.MiddlewareKey;
+import dev.relism.flash.routing.MiddlewareNode;
import java.nio.charset.StandardCharsets;
import java.util.List;
@@ -44,21 +45,16 @@ import java.util.Map;
* app.install(new LimiterExtension(
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
*
- * // inside FlashExtension.routes() or after install():
+ * // inside a FlashContext.onReady(...) callback:
* Guard guard = ctx.require(Guard.class);
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
* }
*/
public final class LimiterExtension implements FlashExtension {
+ private static final MiddlewareKey LIMIT = MiddlewareKey.of("flash.limiter.limit");
private final LimiterConfig config;
- /**
- * Rate limiting runs before authentication — cheaper check rejects over-limit
- * requests before any token validation occurs.
- */
- @Override public int priority() { return ExtensionPhase.EARLY.value; }
-
/** Installs with default config (only the built-in {@code "ip"} resolver). */
public LimiterExtension() {
this(new LimiterConfig());
@@ -70,7 +66,7 @@ public final class LimiterExtension implements FlashExtension {
}
@Override
- public void provide(FlashContext ctx) {
+ public void configure(FlashRegistrar> app, FlashContext ctx) {
BucketStore store = new BucketStore();
Guard guard = new Guard(config, store);
@@ -90,17 +86,15 @@ public final class LimiterExtension implements FlashExtension {
ann.strategy().create()
);
- return List.of(buildMiddleware(resolver, cfg, store));
+ return List.of(MiddlewareNode.of(LIMIT, buildMiddleware(resolver, cfg, store)));
+ });
+ ctx.onReady(() -> {
+ try {
+ OpenApiIntegration.register(ctx);
+ } catch (NoClassDefFoundError ignored) {
+ // flash-ext-openapi not available — OpenAPI integration disabled
+ }
});
- }
-
- @Override
- public void routes(FlashRegistrar> app, FlashContext ctx) {
- try {
- OpenApiIntegration.register(ctx);
- } catch (NoClassDefFoundError ignored) {
- // flash-ext-openapi not available — OpenAPI integration disabled
- }
}
// ── Package-private helper — shared with Guard ────────────────────────────
diff --git a/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/flash/ext/limiter/LimiterOpenApiInteropTest.java b/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/flash/ext/limiter/LimiterOpenApiInteropTest.java
index 168b4fc..f556696 100644
--- a/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/flash/ext/limiter/LimiterOpenApiInteropTest.java
+++ b/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/flash/ext/limiter/LimiterOpenApiInteropTest.java
@@ -41,7 +41,8 @@ class LimiterOpenApiInteropTest {
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
- new LimiterExtension().routes(null, ctx);
+ new LimiterExtension().configure(null, ctx);
+ ctx.complete();
assertEquals(1, registry.contributors().size());
}
@@ -51,7 +52,8 @@ class LimiterOpenApiInteropTest {
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
- new LimiterExtension().routes(null, ctx);
+ new LimiterExtension().configure(null, ctx);
+ ctx.complete();
OpenApiContributor contributor = registry.contributors().getFirst();
OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class);
@@ -73,7 +75,8 @@ class LimiterOpenApiInteropTest {
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
- new LimiterExtension().routes(null, ctx);
+ new LimiterExtension().configure(null, ctx);
+ ctx.complete();
OpenApiContributor contributor = registry.contributors().getFirst();
OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class);
diff --git a/flash-extensions/flash-ext-mcp/docs/README.md b/flash-extensions/flash-ext-mcp/docs/README.md
index 4b30265..643ce0b 100644
--- a/flash-extensions/flash-ext-mcp/docs/README.md
+++ b/flash-extensions/flash-ext-mcp/docs/README.md
@@ -53,4 +53,6 @@ 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/keycloak.md b/flash-extensions/flash-ext-mcp/docs/keycloak.md
new file mode 100644
index 0000000..6326541
--- /dev/null
+++ b/flash-extensions/flash-ext-mcp/docs/keycloak.md
@@ -0,0 +1,107 @@
+# 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 5a23e3d..43e3903 100644
--- a/flash-extensions/flash-ext-mcp/docs/security.md
+++ b/flash-extensions/flash-ext-mcp/docs/security.md
@@ -1,5 +1,10 @@
# Security
+Provider-specific setup steps (not generic OAuth2 mechanics) live in separate cookbooks —
+[`keycloak.md`](keycloak.md) for Keycloak: enabling Dynamic Client Registration, why the RFC 8707
+audience mapper needs to go on the built-in `basic` scope instead of a custom one, and the exact
+Allowed Client Scopes configuration `scopes_supported` needs to actually work.
+
## `McpSecurity`
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-oidc` being
@@ -34,44 +39,120 @@ MCP-only install, no OAuth2 anywhere in the app), the first such reference throw
fails, and only when there's something to fail. This mirrors `OidcExtension`'s own lazy bridge to
`flash-ext-openapi` — same technique, same reason.
-## OAuth2 resolution details
+## OAuth2 resolution details — zero-config by default
-When oidc is available and `security() != NONE`:
+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()` — the same
- Bearer-token/JWKS validation path used everywhere else in Flash5. No JWT parsing or JWKS
- handling is reimplemented here.
-2. If `McpConfig.resourceIdentifier(...)` is set, an additional audience guard runs after
- `protect()`: it reads the validated claims from `ClaimsHolder` and rejects (`403`) any token
- whose `aud` claim does not include the configured resource identifier — **RFC 8707 Resource
- Indicators / audience binding**. This is genuinely new behavior, not something
- `flash-ext-oidc` does on its own: `OidcMiddleware` 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. If `resourceIdentifier(...)` is left unset, only standard bearer validation runs — no
- audience binding. Fine for a first integration; RFC 8707 becomes meaningful once you have
- more than one resource server sharing the same authorization server.
+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
-If both `resourceIdentifier(...)` and `authorizationServerIssuer(...)` are set (and the endpoint
-ends up protected), `flash-ext-mcp` publishes a Protected Resource Metadata document at
-`/.well-known/oauth-protected-resource{rootPath}`:
+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"] }
```
-This lets a spec-compliant MCP client discover which authorization server to use without
-out-of-band configuration. `authorizationServerIssuer` has to be supplied explicitly because
-`flash-ext-oidc` does not expose its resolved issuer/discovery metadata through `FlashContext` —
-only `OidcMiddleware` and `JwtValidator` are registered there. Passing it separately avoids
-reaching into `flash-ext-oidc` internals for a value the app owner already has at hand (it's the
-same issuer they configured `OidcExtension` with).
+`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.
-Without an issuer configured, bearer validation still works exactly the same — the client just
-needs the authorization server configured out-of-band instead of discovering it automatically.
+### `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`:
+
+```java
+@Tool(name = "delete_route", description = "Delete a route")
+@RolesAllowed("admin")
+public class DeleteRouteTool extends McpTool {
+ @Override public ToolResponse call(ToolArguments args) { ... }
+}
+```
+
+This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
+the thing that makes these annotations work on a `RequestHandler`) — it can't: every tool shares
+one HTTP route (`POST {rootPath}`), already wrapped by whatever `McpSecurity` resolved above, so
+there is no per-tool route to attach a different middleware chain to. Instead,
+`McpOidcIntegration.compileToolPolicy` reads the annotations once at boot (`McpRegistry.scan`)
+and compiles them into a closure (`McpAuthPolicy`) that `McpDispatcher` runs *after* the
+route-wide auth has already succeeded and *before* invoking the specific tool named in the
+`tools/call` request — narrowing what's already-authenticated, not replacing it. A denial is a
+normal `isError: true` tool result (see `ToolResponse.error`), not an HTTP-level rejection — the
+model sees why, the same as any other tool failure.
+
+Roles are read via `OidcUser#hasRole` against `McpConfig.rolesClaimPath(...)` (default
+`"realm_access.roles"`, matching `OidcConfig`'s own default — set this explicitly if the two
+diverge; there's no way to read `OidcConfig`'s actual configured value from here). Scopes use
+`OidcUser#hasScope`'s built-in default claim paths (`scope`/`scp`), no extra config needed.
+`@ScopesAllowed(match = ScopesAllowed.Match.ANY)` and multi-role `@RolesAllowed({"admin",
+"editor"})` (OR semantics) both work exactly as they do on a `RequestHandler`.
+
+**`@Authenticated` alone has no effect and fails boot.** Once oidc is active for a server, every
+tool call is already authenticated — there's no per-tool public/authenticated split the way
+there is for HTTP routes, so a bare `@Authenticated` on a tool can't mean anything and would
+silently do nothing if allowed to compile. Boot fails instead, with a message pointing at
+`@RolesAllowed`/`@ScopesAllowed` as the actual narrowing mechanism.
+
+**Annotating a tool without active OAuth2 also fails boot**, not silently at request time: if
+`@RolesAllowed`/`@ScopesAllowed`/`@Authenticated` shows up on a tool while `McpSecurity` resolved
+to unprotected (`NONE`, or `AUTO` with no oidc installed), that's very likely a forgotten
+`OidcExtension` install or a `McpSecurity.NONE` left over from local dev — `IllegalStateException`
+at `app.start()`.
## The `HttpException` safety net
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
new file mode 100644
index 0000000..088d35e
--- /dev/null
+++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java
@@ -0,0 +1,23 @@
+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 3aecedb..da6315f 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
@@ -12,7 +12,7 @@ import java.util.List;
* .rootPath("/mcp")
* .toolsPackage("com.example.tools")
* .security(McpSecurity.REQUIRED)
- * .resourceIdentifier("https://mcp.example.com/mcp")
+ * .scopesSupported("openid", "profile", "email")
* .build();
* }
*/
@@ -27,6 +27,8 @@ public final class McpConfig {
private final String resourceIdentifier;
private final String authorizationServerIssuer;
private final List allowedOrigins;
+ private final List scopesSupported;
+ private final String rolesClaimPath;
private McpConfig(Builder b) {
this.name = b.name;
@@ -38,6 +40,8 @@ public final class McpConfig {
this.resourceIdentifier = b.resourceIdentifier;
this.authorizationServerIssuer = b.authorizationServerIssuer;
this.allowedOrigins = List.copyOf(b.allowedOrigins);
+ this.scopesSupported = List.copyOf(b.scopesSupported);
+ this.rolesClaimPath = b.rolesClaimPath;
}
String name() { return name; }
@@ -49,6 +53,8 @@ public final class McpConfig {
String resourceIdentifier() { return resourceIdentifier; }
String authorizationServerIssuer() { return authorizationServerIssuer; }
List allowedOrigins() { return allowedOrigins; }
+ List scopesSupported() { return scopesSupported; }
+ String rolesClaimPath() { return rolesClaimPath; }
public static Builder builder(String name) { return new Builder(name); }
@@ -62,6 +68,8 @@ public final class McpConfig {
private String resourceIdentifier;
private String authorizationServerIssuer;
private final List allowedOrigins = new ArrayList<>();
+ private final List scopesSupported = new ArrayList<>();
+ private String rolesClaimPath = "realm_access.roles";
private Builder(String name) {
if (name == null || name.isBlank())
@@ -85,18 +93,22 @@ public final class McpConfig {
public Builder security(McpSecurity security) { this.security = security; return this; }
/**
- * Resource identifier used for RFC 8707 audience binding: tokens whose {@code aud} claim
- * does not include this value are rejected. Optional — if unset, only standard bearer
- * validation (signature/issuer/expiry) is enforced, not audience binding.
+ * 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}.
*/
public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; }
/**
- * Authorization server issuer URL, used to publish an RFC 9728 Protected Resource
- * Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}} so MCP
- * clients can discover it automatically. Requires {@link #resourceIdentifier(String)}
- * to also be set. Optional — without it, bearer validation still works, clients just
- * need the authorization server configured out-of-band.
+ * 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; }
@@ -107,6 +119,28 @@ 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.
+ */
+ public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
+
+ /**
+ * Claim path used to resolve roles for {@code @RolesAllowed} on an {@link McpTool} —
+ * same dot-path syntax and default (Keycloak's {@code realm_access.roles}) as {@code
+ * OidcConfig#rolesClaimPath()}. Set this only if the two configs diverge; there is no
+ * way to auto-derive it from the installed {@code OidcExtension} (see {@code
+ * docs/security.md}'s {@code @RolesAllowed}/{@code @ScopesAllowed} section for why).
+ */
+ public Builder rolesClaimPath(String rolesClaimPath) { this.rolesClaimPath = rolesClaimPath; 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 f53d45b..86271d1 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
@@ -17,7 +17,11 @@ import java.io.IOException;
* error — the model needs to see it. Everything else that goes wrong (bad params, unknown
* 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.
+ * 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
+ * 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.
*/
final class McpDispatcher {
@@ -132,12 +136,17 @@ final class McpDispatcher {
if (tool == null)
throw McpProtocolException.invalidParams("Unknown tool: " + name);
- ToolArguments args = new ToolArguments(params.path("arguments"));
ToolResponse result;
- try {
- result = tool.instance().call(args);
- } catch (Exception e) {
- result = ToolResponse.error("Tool \"" + name + "\" failed: " + e.getMessage());
+ String denied = tool.policy() != null ? tool.policy().check().get() : null;
+ if (denied != null) {
+ result = ToolResponse.error("Tool \"" + name + "\" denied: " + denied);
+ } else {
+ ToolArguments args = new ToolArguments(params.path("arguments"));
+ try {
+ result = tool.instance().call(args);
+ } catch (Exception e) {
+ result = ToolResponse.error("Tool \"" + name + "\" failed: " + e.getMessage());
+ }
}
ToolResponse finalResult = result;
writeResult(res, id, gen -> {
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 e38e24a..9a3dd3e 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
@@ -25,14 +25,14 @@ import java.util.List;
* .build()))
* .start();
*
- * // With flash-ext-oidc as the OAuth2 resource server
+ * // 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)
- * .resourceIdentifier("https://mcp.example.com/mcp")
- * .authorizationServerIssuer("https://auth.example.com/realms/myrealm")
* .build()))
* .start();
* }
@@ -51,42 +51,40 @@ public class McpExtension implements FlashExtension {
this.config = config;
}
- /**
- * Everything — scanning, binding, security resolution, route registration — happens here
- * rather than in {@link #provide}, because binding a tool calls its {@code onInit()}, which
- * may call {@code require()} on services other extensions registered lazily via
- * {@code ctx.supply()}. Per {@link FlashExtension}'s contract, {@code require()} is only
- * safe once {@code routes()} runs, after every extension's {@code provide()} phase has
- * completed and {@code FlashContext.resolveAll()} has run.
- */
@Override
- public void routes(FlashRegistrar> app, FlashContext ctx) {
- McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx);
+ public void configure(FlashRegistrar> app, FlashContext ctx) {
+ ctx.onReady(() -> registerRoutes(app, ctx));
+ }
+
+ 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, config.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()));
-
- Middleware security = resolveSecurity(ctx);
- if (security != null) chain.add(security);
+ 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, security != null);
+ registerResourceMetadata(app, secured);
}
- private Middleware resolveSecurity(FlashContext ctx) {
+ private McpOidcIntegration.Resolved resolveSecurity(FlashContext ctx) {
if (config.security() == McpSecurity.NONE) return null;
- Middleware oidcSecurity;
+ McpOidcIntegration.Resolved resolved;
try {
- oidcSecurity = McpOidcIntegration.resolve(ctx, config);
+ resolved = McpOidcIntegration.resolve(ctx, config);
} catch (NoClassDefFoundError e) {
- oidcSecurity = null; // flash-ext-oidc not on the classpath at all
+ resolved = null; // flash-ext-oidc not on the classpath at all
}
- if (oidcSecurity != null) return oidcSecurity;
+ if (resolved != null) return resolved;
if (config.security() == McpSecurity.REQUIRED) {
throw new IllegalStateException(
@@ -102,17 +100,20 @@ public class McpExtension implements FlashExtension {
return null;
}
- private void registerResourceMetadata(FlashRegistrar> app, boolean secured) {
- if (!secured) return;
- String resourceId = config.resourceIdentifier();
- String issuer = config.authorizationServerIssuer();
- if (resourceId == null || resourceId.isBlank() || issuer == null || issuer.isBlank()) return;
-
- String body = McpResourceMetadata.build(resourceId, issuer);
+ /**
+ * 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 body;
+ return McpResourceMetadata.build(
+ secured.resourceIdentifier().apply(req), secured.issuer(), config.scopesSupported());
});
}
}
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
index 3986176..a12a3b4 100644
--- 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
@@ -1,45 +1,84 @@
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} is 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.
+ *
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() {}
- /** Returns the security {@link Middleware} to apply, or {@code null} if oidc is not installed. */
- static Middleware resolve(FlashContext ctx, McpConfig config) {
+ /** Everything {@link McpExtension} needs once oidc security is resolved. */
+ record Resolved(Middleware security, String issuer, 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;
- Middleware protect = oidc.get().protect();
- String resourceId = config.resourceIdentifier();
- if (resourceId == null || resourceId.isBlank()) return protect;
+ 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();
- return Middleware.of(protect, audienceGuard(resourceId));
+ Middleware protect = oidcMw.protect(resourceMetadataPath);
+ Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
+ return new Resolved(secured, issuer, resourceId);
}
- /** RFC 8707 audience binding: rejects tokens whose {@code aud} claim doesn't include ours. */
- private static Middleware audienceGuard(String resourceIdentifier) {
+ /**
+ * 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();
- if (claims != null && !audienceMatches(claims.get("aud"), resourceIdentifier)) {
+ 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);
@@ -53,4 +92,88 @@ final class McpOidcIntegration {
}
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 d3a0bb7..dc653fe 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
@@ -24,7 +24,8 @@ final class McpRegistry {
private static final String EMPTY_ARRAY = "[]";
- record RegisteredTool(String name, McpTool instance) {}
+ /** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */
+ record RegisteredTool(String name, McpTool instance, McpAuthPolicy policy) {}
record RegisteredResource(String uri, McpResource instance) {}
record RegisteredPrompt(String name, McpPrompt instance) {}
@@ -38,7 +39,16 @@ final class McpRegistry {
private McpRegistry() {}
- static McpRegistry scan(String packageName, FlashContext ctx) {
+ /**
+ * @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 forwarded to {@code @RolesAllowed} checks; see
+ * {@link McpConfig#rolesClaimPath(String)}.
+ */
+ static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) {
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
McpRegistry registry = new McpRegistry();
@@ -46,7 +56,8 @@ final class McpRegistry {
Tool ann = cls.getAnnotation(Tool.class);
McpTool instance = instantiate(cls);
instance.bind(ctx);
- if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance)) != null)
+ McpAuthPolicy policy = compileToolPolicy(cls, oidcActive, rolesClaimPath);
+ if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null)
throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\"");
}
for (Class extends McpResource> cls : found.resources()) {
@@ -163,6 +174,24 @@ 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 c3c9fac..55a7eb9 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
@@ -1,17 +1,25 @@
package dev.relism.flash.ext.mcp;
+import java.util.List;
+
/** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */
final class McpResourceMetadata {
private McpResourceMetadata() {}
- static String build(String resourceIdentifier, String authorizationServerIssuer) {
+ /** {@code scopesSupported} is optional per RFC 9728 — omitted from the document if empty. */
+ static String build(String resourceIdentifier, String authorizationServerIssuer, List scopesSupported) {
return McpJson.buildString(gen -> {
gen.writeStartObject();
gen.writeStringField("resource", resourceIdentifier);
gen.writeArrayFieldStart("authorization_servers");
gen.writeString(authorizationServerIssuer);
gen.writeEndArray();
+ if (!scopesSupported.isEmpty()) {
+ gen.writeArrayFieldStart("scopes_supported");
+ for (String scope : scopesSupported) gen.writeString(scope);
+ gen.writeEndArray();
+ }
gen.writeEndObject();
});
}
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
index 3b14af1..8f0bc37 100644
--- 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
@@ -19,6 +19,8 @@ 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;
/**
@@ -56,16 +58,27 @@ final class FakeOidcProvider implements AutoCloseable {
/** 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 claims = new JWTClaimsSet.Builder()
+ JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
.issuer(issuer)
.subject(subject)
.audience(audience)
.issueTime(Date.from(Instant.now()))
- .expirationTime(Date.from(Instant.now().plusSeconds(300)))
- .build();
+ .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(), claims);
+ new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), builder.build());
jwt.sign(new RSASSASigner(rsaKey));
return jwt.serialize();
} catch (Exception e) {
@@ -73,6 +86,8 @@ final class FakeOidcProvider implements AutoCloseable {
}
}
+ private static final String[] NO_ROLES = new String[0];
+
private String discoveryDocument() {
return "{"
+ "\"issuer\":\"" + issuer + "\","
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
new file mode 100644
index 0000000..fb31b9c
--- /dev/null
+++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java
@@ -0,0 +1,150 @@
+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 org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.net.ServerSocket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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 FlashApp app;
+ private FakeOidcProvider provider;
+
+ @AfterEach
+ void tearDown() {
+ if (app != null) app.stop();
+ if (provider != null) provider.close();
+ }
+
+ @Test
+ void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception {
+ int port = bootSecuredApp(SECURED_TOOLS);
+ String resourceId = "http://127.0.0.1:" + port + "/mcp";
+
+ String noRole = provider.signToken("user-1", resourceId, null);
+ HttpResponse denied = callTool(port, "admin_only", noRole);
+ assertEquals(200, denied.statusCode());
+ assertTrue(denied.body().contains("\"isError\":true"), denied.body());
+ assertTrue(denied.body().contains("missing required role"), denied.body());
+
+ String withRole = provider.signToken("user-1", resourceId, null, "admin");
+ HttpResponse allowed = callTool(port, "admin_only", withRole);
+ assertEquals(200, allowed.statusCode());
+ assertTrue(allowed.body().contains("\"isError\":false"), allowed.body());
+ assertTrue(allowed.body().contains("ok"), allowed.body());
+ }
+
+ @Test
+ void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception {
+ int port = bootSecuredApp(SECURED_TOOLS);
+ String resourceId = "http://127.0.0.1:" + port + "/mcp";
+
+ String noScope = provider.signToken("user-1", resourceId, "read");
+ HttpResponse denied = callTool(port, "write_only", noScope);
+ assertEquals(200, denied.statusCode());
+ assertTrue(denied.body().contains("\"isError\":true"), denied.body());
+ assertTrue(denied.body().contains("missing required scope"), denied.body());
+
+ String withScope = provider.signToken("user-1", resourceId, "read write");
+ HttpResponse allowed = callTool(port, "write_only", withScope);
+ assertEquals(200, allowed.statusCode());
+ assertTrue(allowed.body().contains("\"isError\":false"), allowed.body());
+ assertTrue(allowed.body().contains("written"), allowed.body());
+ }
+
+ @Test
+ void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception {
+ int port = bootSecuredApp(SECURED_TOOLS);
+ String resourceId = "http://127.0.0.1:" + port + "/mcp";
+
+ String plain = provider.signToken("user-1", resourceId, null);
+ HttpResponse resp = callTool(port, "open", plain);
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("\"isError\":false"), resp.body());
+ assertTrue(resp.body().contains("open"), resp.body());
+ }
+
+ @Test
+ void toolAnnotated_butSecurityNone_failsAtBoot() throws Exception {
+ provider = new FakeOidcProvider();
+ int port = freePort();
+ app = FlashApp.create(port);
+ 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.NONE)
+ .build()));
+
+ IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start());
+ assertTrue(e.getMessage().contains("no active OAuth2 protection"), e.getMessage());
+ }
+
+ @Test
+ void bareAuthenticated_hasNoEffect_failsAtBoot() throws Exception {
+ provider = new FakeOidcProvider();
+ int port = freePort();
+ app = FlashApp.create(port);
+ app.install(new OidcExtension(OidcConfig.builder(
+ provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
+ app.install(new McpExtension(McpConfig.builder("secure-server")
+ .toolsPackage(AUTHENTICATED_ONLY_TOOLS)
+ .security(McpSecurity.REQUIRED)
+ .build()));
+
+ IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start());
+ assertTrue(e.getMessage().contains("no effect"), e.getMessage());
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ private int bootSecuredApp(String toolsPackage) throws Exception {
+ provider = new FakeOidcProvider();
+ int port = freePort();
+
+ app = FlashApp.create(port);
+ 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(McpSecurity.REQUIRED)
+ .build()));
+ app.start();
+ return port;
+ }
+
+ private static HttpResponse callTool(int port, String toolName, String token) throws Exception {
+ String body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"" + toolName + "\"}}";
+ HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/mcp"))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .header("Authorization", "Bearer " + token)
+ .POST(HttpRequest.BodyPublishers.ofString(body));
+ return HttpClient.newHttpClient().send(req.build(), HttpResponse.BodyHandlers.ofString());
+ }
+
+ private static int freePort() throws Exception {
+ try (ServerSocket s = new ServerSocket(0)) {
+ return s.getLocalPort();
+ }
+ }
+}
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
index 416e4ed..ee040ad 100644
--- 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
@@ -88,6 +88,69 @@ class McpExtensionSecurityTest {
assertTrue(resp.body().contains("\"protocolVersion\""));
}
+ @Test
+ void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception {
+ int port = bootSecuredApp(null);
+ String derivedResourceId = "http://127.0.0.1:" + port + "/mcp";
+
+ String matching = provider.signToken("user-1", derivedResourceId);
+ assertEquals(200, post(port, initializeBody(), matching).statusCode());
+
+ String mismatched = provider.signToken("user-1", "https://someone-else.example.com/resource");
+ assertEquals(403, post(port, initializeBody(), mismatched).statusCode());
+ }
+
+ @Test
+ void required_withOidc_missingToken_challengeIncludesResourceMetadata() throws Exception {
+ int port = bootSecuredApp(null);
+
+ HttpResponse resp = post(port, initializeBody(), null);
+ assertEquals(401, resp.statusCode());
+ String challenge = resp.headers().firstValue("WWW-Authenticate").orElse("");
+ assertTrue(challenge.contains(
+ "resource_metadata=\"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp\""),
+ "WWW-Authenticate: " + challenge);
+ }
+
+ @Test
+ void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() throws Exception {
+ int port = bootSecuredApp(null);
+
+ HttpResponse resp = HttpClient.newHttpClient().send(
+ HttpRequest.newBuilder(URI.create(
+ "http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp")).GET().build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("\"resource\":\"http://127.0.0.1:" + port + "/mcp\""), resp.body());
+ assertTrue(resp.body().contains("\"authorization_servers\":[\"" + provider.issuer() + "\"]"), resp.body());
+ assertTrue(!resp.body().contains("scopes_supported"), "scopes_supported must be omitted when unset: " + resp.body());
+ }
+
+ @Test
+ void scopesSupported_published_inProtectedResourceMetadata() throws Exception {
+ provider = new FakeOidcProvider();
+ int port = freePort();
+
+ app = FlashApp.create(port);
+ app.install(new OidcExtension(OidcConfig.builder(
+ provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
+ app.install(new McpExtension(McpConfig.builder("secure-server")
+ .toolsPackage(TOOLS_PACKAGE)
+ .security(McpSecurity.REQUIRED)
+ .scopesSupported("openid", "profile", "email")
+ .build()));
+ app.start();
+
+ HttpResponse resp = HttpClient.newHttpClient().send(
+ HttpRequest.newBuilder(URI.create(
+ "http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp")).GET().build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ assertEquals(200, resp.statusCode());
+ assertTrue(resp.body().contains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]"), resp.body());
+ }
+
// ── Helpers ──────────────────────────────────────────────────────────────
private int bootSecuredApp(String resourceIdentifier) throws Exception {
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 20503a1..a3faa29 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());
+ McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), false, "realm_access.roles");
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()));
+ () -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), false, "realm_access.roles"));
}
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/authfixtures/authenticatedonly/PointlessAuthTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java
new file mode 100644
index 0000000..9d9a622
--- /dev/null
+++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java
@@ -0,0 +1,20 @@
+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
new file mode 100644
index 0000000..1bd96f5
--- /dev/null
+++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java
@@ -0,0 +1,18 @@
+package dev.relism.flash.ext.mcp.authfixtures.secured;
+
+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.RolesAllowed;
+
+@Tool(name = "admin_only", description = "Only callable with the admin role")
+@RolesAllowed("admin")
+public class AdminOnlyTool extends McpTool {
+
+ @Override
+ public ToolResponse call(ToolArguments args) {
+ return ToolResponse.success(new TextContent("ok"));
+ }
+}
diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/OpenTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/OpenTool.java
new file mode 100644
index 0000000..8040710
--- /dev/null
+++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/OpenTool.java
@@ -0,0 +1,17 @@
+package dev.relism.flash.ext.mcp.authfixtures.secured;
+
+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;
+
+/** No role/scope annotation — any authenticated caller, confirms unrelated tools are unaffected. */
+@Tool(name = "open", description = "Callable by anyone already authenticated")
+public class OpenTool extends McpTool {
+
+ @Override
+ public ToolResponse call(ToolArguments args) {
+ return ToolResponse.success(new TextContent("open"));
+ }
+}
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
new file mode 100644
index 0000000..14b390f
--- /dev/null
+++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java
@@ -0,0 +1,18 @@
+package dev.relism.flash.ext.mcp.authfixtures.secured;
+
+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.ScopesAllowed;
+
+@Tool(name = "write_only", description = "Only callable with the write scope")
+@ScopesAllowed("write")
+public class WriteScopeTool extends McpTool {
+
+ @Override
+ public ToolResponse call(ToolArguments args) {
+ return ToolResponse.success(new TextContent("written"));
+ }
+}
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java
index f7f59a8..10502f7 100644
--- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java
+++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java
@@ -7,6 +7,8 @@ import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
+import dev.relism.flash.routing.MiddlewareKey;
+import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.models.Request;
import javax.net.ssl.SSLContext;
@@ -54,6 +56,7 @@ import java.util.*;
* }
*/
public class OidcExtension implements FlashExtension {
+ private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.oidc.policy");
private final OidcConfig config;
@@ -71,7 +74,7 @@ public class OidcExtension implements FlashExtension {
// ── Phase 1: services ─────────────────────────────────────────────────────
@Override
- public void provide(FlashContext ctx) {
+ public void configure(FlashRegistrar> app, FlashContext ctx) {
HttpClient http = buildHttpClient(config);
// Discover provider endpoints (blocking; fail fast at startup).
@@ -91,14 +94,12 @@ public class OidcExtension implements FlashExtension {
ctx.addAnnotationProcessor(handlerClass -> {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
- return policy != null ? List.of(oidcMw.policyMiddleware(policy)) : List.of();
+ return policy != null ? List.of(MiddlewareNode.of(POLICY, oidcMw.policyMiddleware(policy))) : List.of();
});
+ ctx.onReady(() -> registerRoutes(app, ctx));
}
- // ── Phase 2: routes ───────────────────────────────────────────────────────
-
- @Override
- public void routes(FlashRegistrar> app, FlashContext ctx) {
+ private void registerRoutes(FlashRegistrar> app, FlashContext ctx) {
String prefix = config.routePrefix();
// ── GET {prefix}/login ────────────────────────────────────────────────
@@ -252,7 +253,7 @@ public class OidcExtension implements FlashExtension {
private String absoluteSelf(Request req, String uri) {
if (!uri.startsWith("/")) return uri;
- return config.selfScheme() + "://" + req.header("Host") + uri;
+ return OidcMiddleware.selfOrigin(req, config.selfScheme()) + uri;
}
private static String enc(String v) {
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java
index 78ef6c8..6afe313 100644
--- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java
+++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java
@@ -65,8 +65,21 @@ public class OidcMiddleware {
* to the login page on failure; API clients receive 401.
*/
public Middleware protect() {
+ return protect(null);
+ }
+
+ /**
+ * Like {@link #protect()}, but a 401 challenge also carries {@code resource_metadata}
+ * (RFC 9728 §5.1), resolved against this request's own scheme/host exactly like
+ * {@link OidcExtension}'s redirect URIs. {@code resourceMetadataPath} is an absolute path
+ * (e.g. {@code "/.well-known/oauth-protected-resource/mcp"}); pass {@code null} for plain
+ * challenges. Used by {@code flash-ext-mcp} to make its Protected Resource Metadata
+ * document discoverable straight from the {@code WWW-Authenticate} header, per the MCP
+ * Authorization spec.
+ */
+ public Middleware protect(String resourceMetadataPath) {
return next -> (req, res) -> {
- Map claims = resolve(req, res);
+ Map claims = resolve(req, res, resourceMetadataPath);
if (claims == null) return null; // redirect already written
ClaimsHolder.set(claims);
try {
@@ -77,6 +90,12 @@ public class OidcMiddleware {
};
}
+ /** OIDC issuer this middleware validates tokens against — the {@code iss} claim it enforces. */
+ public String issuer() { return config.issuer(); }
+
+ /** Scheme used to build this app's own absolute URLs — see {@link OidcConfig#selfScheme()}. */
+ public String selfScheme() { return config.selfScheme(); }
+
/**
* Silently populates {@link ClaimsHolder} if a valid bearer token or session cookie
* is present, but never rejects or redirects unauthenticated requests. Use this on
@@ -195,13 +214,17 @@ public class OidcMiddleware {
* {@code res}. Throws {@link HttpException} 401/403 for API clients.
*/
private Map resolve(Request req, Response res) {
+ return resolve(req, res, null);
+ }
+
+ private Map resolve(Request req, Response res, String resourceMetadataPath) {
// 1. Bearer token
String bearerToken = extractBearerToken(req.header("Authorization"));
if (bearerToken != null) {
try {
return validator.validate(bearerToken);
} catch (HttpException e) {
- res.header("WWW-Authenticate", invalidTokenChallenge());
+ res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath));
throw e;
}
}
@@ -233,7 +256,7 @@ public class OidcMiddleware {
// 3. No valid credentials
String accept = req.header("Accept");
if (accept != null && accept.contains("application/json")) {
- res.header("WWW-Authenticate", bearerChallenge());
+ res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath));
throw HttpException.unauthorized();
}
@@ -300,11 +323,21 @@ public class OidcMiddleware {
}
String bearerChallenge() {
- return BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
+ return bearerChallenge(null, null);
+ }
+
+ private String bearerChallenge(Request req, String resourceMetadataPath) {
+ String base = BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
+ if (resourceMetadataPath == null) return base;
+ return base + ", resource_metadata=\"" + quoted(absoluteSelf(req, resourceMetadataPath)) + "\"";
}
String invalidTokenChallenge() {
- return bearerChallenge() + ", error=\"invalid_token\"";
+ return invalidTokenChallenge(null, null);
+ }
+
+ private String invalidTokenChallenge(Request req, String resourceMetadataPath) {
+ return bearerChallenge(req, resourceMetadataPath) + ", error=\"invalid_token\"";
}
String insufficientScopeChallenge(String[] requiredScopes) {
@@ -312,6 +345,28 @@ public class OidcMiddleware {
+ quoted(spaceDelimited(requiredScopes)) + "\"";
}
+ private String absoluteSelf(Request req, String path) {
+ if (!path.startsWith("/")) return path;
+ return selfOrigin(req, config.selfScheme()) + path;
+ }
+
+ /**
+ * {@code scheme://host} clients actually reach this app on — the basis for every absolute
+ * URL it publishes about itself (OAuth2 {@code redirect_uri}, the RFC 9728 resource
+ * identifier and the {@code resource_metadata} challenge). Behind a reverse proxy the
+ * request's own {@code Host} is the upstream address the proxy dialled, so
+ * {@code X-Forwarded-Host}/{@code -Proto} win whenever present: without them the app would
+ * name an address no client can resolve, and OAuth2 discovery fails with no error anyone
+ * can trace back to here. Trusted unconditionally — a caller able to reach this app without
+ * passing the proxy can do worse than spoof a self URL.
+ */
+ public static String selfOrigin(Request req, String fallbackScheme) {
+ String forwardedHost = req.header("X-Forwarded-Host");
+ if (forwardedHost == null) return fallbackScheme + "://" + req.header("Host");
+ String forwardedProto = req.header("X-Forwarded-Proto");
+ return (forwardedProto != null ? forwardedProto : fallbackScheme) + "://" + forwardedHost;
+ }
+
private static String spaceDelimited(String[] values) {
if (values == null || values.length == 0) return "";
StringBuilder sb = new StringBuilder();
diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java
index d84bcf3..41b8bdf 100644
--- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java
+++ b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java
@@ -104,6 +104,7 @@ class OidcOpenApiInteropTest {
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
+ ctx.complete();
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java
index 92a1bb5..aff5f09 100644
--- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java
@@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import dev.relism.flash.extension.FlashContext;
-import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
+import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.RouteEvent;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
@@ -65,7 +65,7 @@ public class OpenApiExtension implements FlashExtension {
// ── FlashExtension ────────────────────────────────────────────────────────
@Override
- public void provide(FlashContext ctx) {
+ public void configure(FlashRegistrar> app, FlashContext ctx) {
OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description);
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
@@ -76,22 +76,20 @@ public class OpenApiExtension implements FlashExtension {
// Collect operation metadata from final compiled routes.
// This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec.
ctx.addRouteListener(event -> addOperationFromEvent(builder, event));
- }
-
- @Override
- public void routes(FlashRegistrar> app, FlashContext ctx) {
- ObjectMapper jsonMapper = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build());
- YAMLMapper yamlMapper = new YAMLMapper();
- OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
+ ctx.onReady(() -> {
+ ObjectMapper jsonMapper = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build());
+ YAMLMapper yamlMapper = new YAMLMapper();
+ OpenApiBuilder resolvedBuilder = ctx.require(OpenApiBuilder.class);
String jsonPath = basePath + ".json";
String yamlPath = basePath + ".yaml";
String swaggerPath = basePath + "/swagger";
String swaggerHtml = buildSwaggerHtml(jsonPath);
- app.get(jsonPath, (req, res) -> { res.type(ContentType.JSON); return jsonMapper.writeValueAsString(builder.build()); });
- app.get(yamlPath, (req, res) -> { res.type(YAML_CONTENT_TYPE); return yamlMapper.writeValueAsString(builder.build()); });
- app.get(swaggerPath, (req, res) -> { res.type(ContentType.TEXT_HTML); return swaggerHtml; });
+ app.get(jsonPath, (req, res) -> { res.type(ContentType.JSON); return jsonMapper.writeValueAsString(resolvedBuilder.build()); });
+ app.get(yamlPath, (req, res) -> { res.type(YAML_CONTENT_TYPE); return yamlMapper.writeValueAsString(resolvedBuilder.build()); });
+ app.get(swaggerPath, (req, res) -> { res.type(ContentType.TEXT_HTML); return swaggerHtml; });
+ });
}
// ── Swagger UI HTML ───────────────────────────────────────────────────────
diff --git a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java
index a92ddc2..b5e4fbd 100644
--- a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java
+++ b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java
@@ -11,7 +11,7 @@ import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.GET;
-import dev.relism.flash.routing.Middleware;
+import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.websocket.WebSocketEndpoint;
import org.junit.jupiter.api.Test;
@@ -41,12 +41,10 @@ class OpenApiExtensionTest {
void provide_collects_operations_and_routes_serve_json_yaml_swagger() throws Exception {
FlashContext ctx = new FlashContext();
OpenApiExtension ext = new OpenApiExtension("/docs", "My API", "2.0.0", "desc");
- ext.provide(ctx);
-
- emitRoute(ctx, HttpMethod.GET, "/health", "/", HealthHandler.class);
-
TestRegistrar app = new TestRegistrar(ctx);
- ext.routes(app, ctx);
+ ext.configure(app, ctx);
+ emitRoute(ctx, HttpMethod.GET, "/health", "/", HealthHandler.class);
+ ctx.complete();
assertNotNull(app.route(HttpMethod.GET, "/docs.json"));
assertNotNull(app.route(HttpMethod.GET, "/docs.yaml"));
@@ -77,9 +75,9 @@ class OpenApiExtensionTest {
ObjectMapper mapper = new ObjectMapper();
ctx.provide(ObjectMapper.class, mapper);
- ext.provide(ctx);
TestRegistrar app = new TestRegistrar(ctx);
- ext.routes(app, ctx);
+ ext.configure(app, ctx);
+ ctx.complete();
Response jsonRes = new Response(200, ContentType.NONE);
Object jsonBody = app.route(HttpMethod.GET, "/openapi.json").handle(null, jsonRes);
@@ -99,10 +97,11 @@ class OpenApiExtensionTest {
void collects_full_runtime_path_from_route_event() {
FlashContext ctx = new FlashContext();
OpenApiExtension ext = new OpenApiExtension();
- ext.provide(ctx);
+ ext.configure(new TestRegistrar(ctx), ctx);
emitRoute(ctx, HttpMethod.GET, "/api/v1/users", "/api/v1", ScopedUsersHandler.class);
+ ctx.complete();
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
Map spec = builder.build();
Map paths = cast(spec.get("paths"));
@@ -114,10 +113,11 @@ class OpenApiExtensionTest {
void normalizes_double_slash_paths_from_events() {
FlashContext ctx = new FlashContext();
OpenApiExtension ext = new OpenApiExtension();
- ext.provide(ctx);
+ ext.configure(new TestRegistrar(ctx), ctx);
emitRoute(ctx, HttpMethod.GET, "//blogs", "/", ScopedUsersHandler.class);
+ ctx.complete();
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
Map spec = builder.build();
Map paths = cast(spec.get("paths"));
@@ -150,7 +150,7 @@ class OpenApiExtensionTest {
private static final class TestRegistrar extends FlashRegistrar {
private final FlashContext ctx;
private final Map routes = new HashMap<>();
- private final List middlewares = new ArrayList<>();
+ private final List middlewares = new ArrayList<>();
private TestRegistrar(FlashContext ctx) {
this.ctx = ctx;
@@ -162,7 +162,7 @@ class OpenApiExtensionTest {
}
@Override
- protected void addRoute(HttpMethod method, String path, RequestHandler handler, List mw) {
+ protected void addRoute(HttpMethod method, String path, RequestHandler handler, List mw) {
routes.put(method.name() + " " + path, handler);
}
@@ -172,7 +172,7 @@ class OpenApiExtensionTest {
}
@Override
- protected void addMiddleware(Middleware mw) {
+ protected void addMiddleware(MiddlewareNode mw) {
middlewares.add(mw);
}
diff --git a/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerExtension.java b/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerExtension.java
index 9630519..5e728ee 100644
--- a/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerExtension.java
+++ b/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerExtension.java
@@ -1,10 +1,9 @@
package dev.relism.flash.ext.routeviewer;
import dev.relism.flash.ext.routeviewer.model.RouteGraph;
-import dev.relism.flash.extension.ExtensionPhase;
import dev.relism.flash.extension.FlashContext;
-import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
+import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.http.ContentType;
/**
@@ -41,9 +40,6 @@ public class RouteViewerExtension implements FlashExtension {
private final String path;
private final RouteGraph graph = new RouteGraph();
- /** Observability — runs last so the viewer sees the complete middleware chain. */
- @Override public int priority() { return ExtensionPhase.LATE.value; }
-
/** Installs the viewer at {@value #DEFAULT_PATH}. */
public RouteViewerExtension() { this(DEFAULT_PATH); }
@@ -54,16 +50,14 @@ public class RouteViewerExtension implements FlashExtension {
public RouteViewerExtension(String path) { this.path = path; }
@Override
- public void provide(FlashContext ctx) {
+ public void configure(FlashRegistrar> app, FlashContext ctx) {
// Register listener before any routes compile — captures everything.
ctx.addRouteListener(graph::add);
- }
-
- @Override
- public void routes(FlashRegistrar> app, FlashContext ctx) {
- app.get(path, new RouteViewerHandler()::handle);
- app.get(path + "/app.js", new RouteViewerStaticHandler("routeviewer/app.js", ContentType.TEXT_JAVASCRIPT)::handle);
- app.get(path + "/app.css", new RouteViewerStaticHandler("routeviewer/app.css", ContentType.TEXT_CSS)::handle);
- app.get(path + "/data", new RouteViewerDataHandler(graph)::handle);
+ ctx.onReady(() -> {
+ app.get(path, new RouteViewerHandler()::handle);
+ app.get(path + "/app.js", new RouteViewerStaticHandler("routeviewer/app.js", ContentType.TEXT_JAVASCRIPT)::handle);
+ app.get(path + "/app.css", new RouteViewerStaticHandler("routeviewer/app.css", ContentType.TEXT_CSS)::handle);
+ app.get(path + "/data", new RouteViewerDataHandler(graph)::handle);
+ });
}
}
diff --git a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java
index 1d83b9c..356d222 100644
--- a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java
+++ b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java
@@ -1,9 +1,11 @@
package dev.relism.flash.ext.view.core;
import dev.relism.flash.extension.FlashContext;
+import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
+import dev.relism.flash.routing.MiddlewareNode;
import java.util.ArrayList;
import java.util.List;
@@ -30,7 +32,7 @@ public abstract class BaseViewExtension implements FlashExtension {
}
@Override
- public final void provide(FlashContext ctx) {
+ public void configure(FlashRegistrar> app, FlashContext ctx) {
ViewRuntimeBridge runtime = createRuntime(List.copyOf(globals));
ctx.provide(ViewRuntimeBridge.class, runtime);
ctx.addAnnotationProcessor(handlerClass -> {
diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java
index d7cbf7f..832bac2 100644
--- a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java
+++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java
@@ -57,15 +57,17 @@ public final class JteExtension extends BaseViewExtension {
}
@Override
- public void routes(FlashRegistrar> app, FlashContext ctx) {
- if (!settings.serveStatics()) return;
- JteStaticServing staticServing = JteStaticServing.load(settings);
- if (staticServing == null) return;
-
- String wildcard = settings.staticPrefix() + "/**";
- StaticJteHandler handler = new StaticJteHandler(staticServing);
- app.get(wildcard, handler::handle);
- app.head(wildcard, (req, res) -> { staticServing.serve(req, res, true); return null; });
+ public void configure(FlashRegistrar> app, FlashContext ctx) {
+ super.configure(app, ctx);
+ ctx.onReady(() -> {
+ if (!settings.serveStatics()) return;
+ JteStaticServing staticServing = JteStaticServing.load(settings);
+ if (staticServing == null) return;
+ String wildcard = settings.staticPrefix() + "/**";
+ StaticJteHandler handler = new StaticJteHandler(staticServing);
+ app.get(wildcard, handler::handle);
+ app.head(wildcard, (req, res) -> { staticServing.serve(req, res, true); return null; });
+ });
}
@Override
diff --git a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java
index f3997f5..fc774d5 100644
--- a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java
+++ b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java
@@ -93,7 +93,9 @@ class JteExtensionTest {
void routes_register_static_wildcard_when_enabled() {
JteExtension ext = new JteExtension(cfg -> cfg.staticPrefix("/assets"));
TestRegistrar app = new TestRegistrar();
- ext.routes(app, new dev.relism.flash.extension.FlashContext());
+ dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext();
+ ext.configure(app, ctx);
+ ctx.complete();
assertTrue(app.routes.containsKey("GET /assets/**"));
assertTrue(app.routes.containsKey("HEAD /assets/**"));
}
@@ -102,7 +104,9 @@ class JteExtensionTest {
void routes_do_not_register_static_when_disabled() {
JteExtension ext = new JteExtension().serveStatics(false);
TestRegistrar app = new TestRegistrar();
- ext.routes(app, new dev.relism.flash.extension.FlashContext());
+ dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext();
+ ext.configure(app, ctx);
+ ctx.complete();
assertTrue(app.routes.isEmpty());
}
@@ -112,7 +116,9 @@ class JteExtensionTest {
.staticPrefix("/assets")
.largeFileThresholdBytes(1));
TestRegistrar app = new TestRegistrar();
- ext.routes(app, new dev.relism.flash.extension.FlashContext());
+ dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext();
+ ext.configure(app, ctx);
+ ctx.complete();
Request req = request("/assets/sample.css", null, null, null);
Response res = new Response(200, dev.relism.flash.http.ContentType.TEXT_PLAIN);
@@ -161,7 +167,7 @@ class JteExtensionTest {
private static final class TestRegistrar extends dev.relism.flash.extension.FlashRegistrar {
private final Map routes = new HashMap<>();
- private final List mws = new ArrayList<>();
+ private final List mws = new ArrayList<>();
@Override
public dev.relism.flash.extension.FlashContext ctx() {
@@ -169,7 +175,7 @@ class JteExtensionTest {
}
@Override
- protected void addRoute(dev.relism.flash.http.HttpMethod method, String path, RequestHandler handler, List mw) {
+ protected void addRoute(dev.relism.flash.http.HttpMethod method, String path, RequestHandler handler, List mw) {
routes.put(method.name() + " " + path, handler);
}
@@ -179,7 +185,7 @@ class JteExtensionTest {
}
@Override
- protected void addMiddleware(dev.relism.flash.routing.Middleware mw) {
+ protected void addMiddleware(dev.relism.flash.routing.MiddlewareNode mw) {
mws.add(mw);
}
}
diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java
index b92ed76..3fc1c7c 100644
--- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java
+++ b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java
@@ -1,9 +1,8 @@
package dev.relism.flash.ext.webbundler;
-import dev.relism.flash.extension.ExtensionPhase;
import dev.relism.flash.extension.FlashContext;
-import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
+import dev.relism.flash.extension.FlashExtension;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -24,11 +23,6 @@ public final class WebBundlerExtension implements FlashExtension {
private ScheduledExecutorService watchLoop;
private Thread shutdownHook;
- @Override
- public int priority() {
- return ExtensionPhase.LATE.value;
- }
-
public WebBundlerExtension() {
this(WebBundlerConfig.builder().build());
}
@@ -38,7 +32,7 @@ public final class WebBundlerExtension implements FlashExtension {
}
@Override
- public void provide(FlashContext ctx) {
+ public void configure(FlashRegistrar> app, FlashContext ctx) {
RuntimeEnvironment environment = modeResolver.resolve(config);
PackageManagerAdapter pmAdapter = new PackageManagerAdapter(config);
FrontendStrategy strategy = frontendTypeResolver.resolve(config.frontendType());
@@ -74,10 +68,7 @@ public final class WebBundlerExtension implements FlashExtension {
shutdownResources(orchestrator);
throw ex;
}
- }
-
- @Override
- public void routes(FlashRegistrar> app, FlashContext ctx) {
+ ctx.onReady(() -> {
WebBundlerRuntime runtime = ctx.require(WebBundlerRuntime.class);
boolean orchestrated = runtime.environment() == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration();
if (orchestrated || config.operationMode() == OperationMode.ORCHESTRATE_ONLY) {
@@ -101,6 +92,7 @@ public final class WebBundlerExtension implements FlashExtension {
res.body(new byte[0]);
return null;
});
+ });
}
private void bootstrapDev(
diff --git a/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java b/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java
index d33ce4d..b5ddc26 100644
--- a/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java
+++ b/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java
@@ -1,7 +1,7 @@
package dev.relism.flash.extension;
import dev.relism.flash.models.RequestHandler;
-import dev.relism.flash.routing.Middleware;
+import dev.relism.flash.routing.MiddlewareNode;
import java.util.List;
@@ -18,5 +18,5 @@ import java.util.List;
*/
@FunctionalInterface
public interface AnnotationProcessor {
- List process(Class extends RequestHandler> handlerClass);
+ List process(Class extends RequestHandler> handlerClass);
}
diff --git a/flash/src/main/java/dev/relism/flash/extension/ExtensionPhase.java b/flash/src/main/java/dev/relism/flash/extension/ExtensionPhase.java
deleted file mode 100644
index 39e5e07..0000000
--- a/flash/src/main/java/dev/relism/flash/extension/ExtensionPhase.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package dev.relism.flash.extension;
-
-/**
- * Semantic execution phases for {@link FlashExtension#priority()}.
- *
- *
Phase determines the order in which annotation-processor middlewares are injected into
- * the chain. Lower value = runs earlier (outermost wrapper = first at request time).
- *
- *
- * Request ──► EARLY middlewares ──► DEFAULT middlewares ──► LATE middlewares ──► handler
- *
- *
- *
Within the same phase, extensions execute in install order (sort is stable).
- * Raw integers are valid for fine-grained ordering within a phase
- * (e.g. {@code ExtensionPhase.EARLY.value + 10}).
- */
-public enum ExtensionPhase {
-
- /** Security guards, rate limiting — must short-circuit before expensive processing. */
- EARLY(100),
-
- /** Normal application extensions. Default when {@link FlashExtension#priority()} is not overridden. */
- DEFAULT(500),
-
- /** Observability, logging, diagnostics — must observe after all business logic. */
- LATE(900);
-
- /** The integer priority value used for sorting. */
- public final int value;
-
- ExtensionPhase(int value) { this.value = value; }
-}
diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashApp.java b/flash/src/main/java/dev/relism/flash/extension/FlashApp.java
index dfe4673..61a83a8 100644
--- a/flash/src/main/java/dev/relism/flash/extension/FlashApp.java
+++ b/flash/src/main/java/dev/relism/flash/extension/FlashApp.java
@@ -9,6 +9,8 @@ import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.routing.Middleware;
+import dev.relism.flash.routing.MiddlewareGraph;
+import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathWsRouterImpl;
import dev.relism.flash.websocket.WebSocketEndpoint;
@@ -19,7 +21,6 @@ import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.util.ArrayList;
-import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
@@ -41,10 +42,9 @@ import java.util.function.Consumer;
*
*
Startup sequence (both {@link #start()} and {@link #startAndBlock()})
*
- *
Extensions sorted by {@link FlashExtension#priority()} — lower first.
- *
All {@link FlashExtension#provide} — register services, processors, listeners.
+ *
All {@link FlashExtension#configure} declarations.
All {@link FlashExtension#routes} — routes registered, services available.
+ *
Ready callbacks register routes with resolved services.
*
Compile all routes into one flat FSM — zero prefix scanning at runtime.
*
Accept loop started.
*
@@ -62,7 +62,7 @@ public final class FlashApp extends FlashRegistrar {
private final ServerHandle server;
private final FlashContext ctx = new FlashContext();
private final List extensions = new ArrayList<>();
- private final List globalMiddlewares = new ArrayList<>();
+ private final List globalMiddlewares = new ArrayList<>();
private final List deferredRoutes = new ArrayList<>();
private final List deferredWsRoutes = new ArrayList<>();
@@ -131,10 +131,7 @@ public final class FlashApp extends FlashRegistrar {
// ── Extensions ────────────────────────────────────────────────────────────
/**
- * Registers an extension for two-phase installation at startup.
- * Install order is irrelevant — all {@link FlashExtension#provide} calls complete
- * before any {@link FlashExtension#routes} call begins.
- * Extensions are sorted by {@link FlashExtension#priority()} before execution.
+ * Registers a declarative extension contribution.
*/
public FlashApp install(FlashExtension ext) {
extensions.add(ext);
@@ -174,7 +171,7 @@ public final class FlashApp extends FlashRegistrar {
// ── FlashRegistrar impl ───────────────────────────────────────────────────
@Override
- protected void addRoute(HttpMethod method, String path, RequestHandler handler, List mw) {
+ protected void addRoute(HttpMethod method, String path, RequestHandler handler, List mw) {
deferredRoutes.add(new RouteDefinition(
method, path, handler, List.of(), mw,
!(handler instanceof SimpleHandler), ctx, "/"));
@@ -186,27 +183,25 @@ public final class FlashApp extends FlashRegistrar {
}
@Override
- protected void addMiddleware(Middleware mw) { globalMiddlewares.add(mw); }
+ protected void addMiddleware(MiddlewareNode mw) { globalMiddlewares.add(mw); }
// ── Boot ─────────────────────────────────────────────────────────────────
private void boot() {
- extensions.sort(Comparator.comparingInt(FlashExtension::priority));
- extensions.forEach(e -> e.provide(ctx));
- ctx.resolveAll();
- extensions.forEach(e -> e.routes(this, ctx));
+ extensions.forEach(e -> e.configure(this, ctx));
+ ctx.complete();
compile();
compileWs();
+ router.compile();
+ wsRouter.compile();
}
// ── Compilation ──────────────────────────────────────────────────────────
- private static final Middleware[] EMPTY_MW = new Middleware[0];
-
/** Middleware chain order per route: Global → Scope → Annotation → Explicit. */
private void compile() {
for (RouteDefinition def : deferredRoutes) {
- List injected;
+ List injected;
if (def.classBasedHandler()) {
injected = def.ctx().processors().stream()
.flatMap(p -> p.process(def.handler().getClass()).stream())
@@ -215,7 +210,8 @@ public final class FlashApp extends FlashRegistrar {
} else {
injected = List.of();
}
- Middleware[] all = concat(globalMiddlewares, def.scopeMiddlewares(), injected, def.explicitMiddlewares());
+ Middleware[] all = MiddlewareGraph.order(def.method() + " " + def.path(),
+ concat(globalMiddlewares, def.scopeMiddlewares(), injected, def.explicitMiddlewares()));
emitEvent(def, all);
router.doRegister(def.method(), def.path(), def.handler(), all);
}
@@ -248,16 +244,12 @@ public final class FlashApp extends FlashRegistrar {
listeners.forEach(l -> l.onRoute(event));
}
- private static Middleware[] concat(List global, List scope,
- List injected, List explicit) {
+ private static List concat(List global, List scope,
+ List injected, List explicit) {
int total = global.size() + scope.size() + injected.size() + explicit.size();
- if (total == 0) return EMPTY_MW;
- Middleware[] all = new Middleware[total];
- int i = 0;
- for (Middleware m : global) all[i++] = m;
- for (Middleware m : scope) all[i++] = m;
- for (Middleware m : injected) all[i++] = m;
- for (Middleware m : explicit) all[i++] = m;
+ if (total == 0) return List.of();
+ List all = new ArrayList<>(total);
+ all.addAll(global); all.addAll(scope); all.addAll(injected); all.addAll(explicit);
return all;
}
}
diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashContext.java b/flash/src/main/java/dev/relism/flash/extension/FlashContext.java
index ed55a30..36bed75 100644
--- a/flash/src/main/java/dev/relism/flash/extension/FlashContext.java
+++ b/flash/src/main/java/dev/relism/flash/extension/FlashContext.java
@@ -1,176 +1,160 @@
package dev.relism.flash.extension;
import java.util.*;
+import java.util.function.Function;
import java.util.function.Supplier;
-/**
- * Central service registry and boot-time hook coordinator.
- *
- *
Every handler, extension, and scope shares one (or a child of one) {@code FlashContext}.
- * Three capabilities:
- *
- *
Service registry — {@link #provide}/{@link #supply}/{@link #require}/{@link #find}.
- *
Annotation processors — middleware injection from handler annotations at boot.
- *
Route listeners — boot-time observation of the route graph.
- *
- *
- *
Eager vs lazy registration
- *
- *
{@link #provide(Class, Object)} — instance is already constructed, registered immediately.
- *
{@link #supply(Class, Supplier)} — factory is registered; it runs once, at
- * {@link #resolveAll()} time (called by {@link FlashApp#start()}) after all
- * {@link FlashExtension#provide} phases complete. The factory may call
- * {@link #require} for its own dependencies — the runtime resolves in topological
- * order automatically and reports circular dependencies with the full cycle path.
- *
- *
- *
A child context (via {@link #child()}) inherits parent services and processors.
- * Services provided on the child are scoped and invisible to the parent.
- */
-public class FlashContext {
+/** Deterministic boot-time service graph, frozen before handlers are initialised. */
+public final class FlashContext {
+ private enum State { DECLARING, RESOLVING, READY }
private final FlashContext parent;
- private final Map, Object> registry = new LinkedHashMap<>();
- private final Map, Supplier>> pending = new LinkedHashMap<>();
- private final List processors = new ArrayList<>();
- private final List routeListeners = new ArrayList<>();
-
- // Lazy caches — nulled whenever the corresponding list is mutated.
+ private final Map, Binding>> bindings = new LinkedHashMap<>();
+ private final List processors = new ArrayList<>();
+ private final List routeListeners = new ArrayList<>();
+ private final List readyCallbacks = new ArrayList<>();
+ private final List children = new ArrayList<>();
+ private final Deque> resolutionPath = new ArrayDeque<>();
+ private State state = State.DECLARING;
private List cachedProcessors;
- private List cachedListeners;
+ private List cachedListeners;
- // DFS stack — tracks in-progress resolutions to detect circular dependencies.
- private final LinkedHashSet> resolutionStack = new LinkedHashSet<>();
-
- public FlashContext() { this.parent = null; }
+ public FlashContext() { parent = null; }
private FlashContext(FlashContext parent) { this.parent = parent; }
- /** Creates a child context that inherits this context's services and processors. */
- public FlashContext child() { return new FlashContext(this); }
+ public FlashContext child() {
+ requireDeclaring();
+ FlashContext child = new FlashContext(this);
+ children.add(child);
+ return child;
+ }
- // ── Service registry ─────────────────────────────────────────────────────
-
- /** Registers an already-constructed {@code instance} under {@code type}. */
+ /** Binds an already-created singleton. Duplicate bindings are always an error. */
public void provide(Class type, T instance) {
- registry.put(type, instance);
+ declare(type, new Binding<>(type, List.of(), ignored -> Objects.requireNonNull(instance, "instance")));
}
- /**
- * Registers a lazy factory for {@code type}. The factory runs once at
- * {@link #resolveAll()} time (or on the first {@link #require} call for this type)
- * and may call {@link #require} for its own dependencies — topological order
- * is resolved automatically.
- *
- *