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 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 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 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 handlerClass); + List process(Class 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()})

*
    - *
  1. Extensions sorted by {@link FlashExtension#priority()} — lower first.
  2. - *
  3. All {@link FlashExtension#provide} — register services, processors, listeners.
  4. + *
  5. All {@link FlashExtension#configure} declarations.
  6. *
  7. {@link FlashContext#resolveAll()} — topo-sort, cycle detection.
  8. - *
  9. All {@link FlashExtension#routes} — routes registered, services available.
  10. + *
  11. Ready callbacks register routes with resolved services.
  12. *
  13. Compile all routes into one flat FSM — zero prefix scanning at runtime.
  14. *
  15. Accept loop started.
  16. *
@@ -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: - *

    - *
  1. Service registry — {@link #provide}/{@link #supply}/{@link #require}/{@link #find}.
  2. - *
  3. Annotation processors — middleware injection from handler annotations at boot.
  4. - *
  5. Route listeners — boot-time observation of the route graph.
  6. - *
- * - *

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. - * - *

{@code
-     * ctx.supply(JwtValidator.class, () ->
-     *     new JwtValidator(ctx.require(OidcProviderMetadata.class).jwksUri()));
-     * }
- */ + /** Declares a no-dependency boot factory. */ public void supply(Class type, Supplier factory) { - pending.put(type, factory); + declare(type, new Binding<>(type, List.of(), ignored -> factory.get())); } - /** - * Returns the service for {@code type}. Checks own scope first, then parent chain. - * Lazy-registered types are resolved on first access. Circular dependencies throw - * {@link IllegalStateException} with the full cycle path. - * - * @throws IllegalStateException if the service is not found anywhere in the context chain - */ + /** Declares a boot factory and its complete dependency set. */ + public void supply(Class type, ServiceFactory factory, Class... dependencies) { + Objects.requireNonNull(factory, "factory"); + declare(type, new Binding<>(type, List.of(dependencies), factory)); + } + + /** One-dependency factory with no application-side context lookup. */ + public void supply(Class type, Class dependency, Function factory) { + supply(type, ignored -> factory.apply(require(dependency)), dependency); + } + + /** Registers work materialised after all services are resolved. */ + public void onReady(Runnable callback) { requireDeclaring(); readyCallbacks.add(Objects.requireNonNull(callback)); } + @SuppressWarnings("unchecked") public T require(Class type) { - Object val = registry.get(type); - if (val != null) return (T) val; - if (pending.containsKey(type)) return resolve(type); + if (state == State.DECLARING) + throw new IllegalStateException("Service graph is still being declared; use FlashContext.onReady(...)"); + Binding binding = bindings.get(type); + if (binding != null) { + verifyDeclaredDependency(type); + return (T) resolve((Binding) binding); + } if (parent != null) return parent.require(type); - throw new IllegalStateException( - "Service not found: " + type.getSimpleName() + - " — register it via FlashContext.provide()/supply() or install the required extension"); + throw new IllegalStateException("No provider declared for " + type.getName() + dependencyTrace()); } - /** Returns the service for {@code type}, or empty if not found in this scope or any parent. */ - @SuppressWarnings("unchecked") public Optional find(Class type) { - Object val = registry.get(type); - if (val != null) return Optional.of((T) val); - if (pending.containsKey(type)) return Optional.of(resolve(type)); - return parent != null ? parent.find(type) : Optional.empty(); + if (state == State.DECLARING) + throw new IllegalStateException("Service graph is still being declared; use FlashContext.onReady(...)"); + if (bindings.containsKey(type)) return Optional.of(require(type)); + return parent == null ? Optional.empty() : parent.find(type); } - - /** Alias for {@link #find} — prefer when semantics are "this may or may not exist". */ public Optional optional(Class type) { return find(type); } - /** - * Eagerly resolves all pending lazy suppliers in topological order. - * Called once by {@link FlashApp#start()} after all {@link FlashExtension#provide} - * phases complete. Any circular dependency is reported with the full cycle path. - */ - void resolveAll() { - new ArrayList<>(pending.keySet()).forEach(this::resolve); - } - - @SuppressWarnings("unchecked") - private T resolve(Class type) { - Object already = registry.get(type); - if (already != null) return (T) already; // resolved during an earlier DFS branch - - if (!resolutionStack.add(type)) { - // type is already on the current DFS path → circular dependency - List> cycle = new ArrayList<>(resolutionStack); - cycle.add(type); - StringBuilder msg = new StringBuilder("Circular dependency: "); - for (int i = 0; i < cycle.size(); i++) { - if (i > 0) msg.append(" → "); - msg.append(cycle.get(i).getSimpleName()); - } - throw new IllegalStateException(msg.toString()); - } - - Supplier factory = pending.get(type); - Object instance = factory.get(); // recursive require() calls happen here - registry.put(type, instance); - pending.remove(type); - resolutionStack.remove(type); - return (T) instance; - } - - // ── Annotation processors ──────────────────────────────────────────────── - - /** Registers an {@link AnnotationProcessor}. Processors run once per class-based handler at boot. */ public void addAnnotationProcessor(AnnotationProcessor processor) { - processors.add(processor); - cachedProcessors = null; + requireDeclaring(); processors.add(Objects.requireNonNull(processor)); cachedProcessors = null; + } + public void addRouteListener(RouteListener listener) { + requireDeclaring(); routeListeners.add(Objects.requireNonNull(listener)); cachedListeners = null; } - /** All processors visible from this context: parent-first, then own. Cached after first call. */ List processors() { if (cachedProcessors != null) return cachedProcessors; - if (parent == null) return cachedProcessors = List.copyOf(processors); - List p = parent.processors(); - if (processors.isEmpty()) return cachedProcessors = p; - List merged = new ArrayList<>(p.size() + processors.size()); - merged.addAll(p); - merged.addAll(processors); - return cachedProcessors = List.copyOf(merged); + List all = parent == null ? new ArrayList<>() : new ArrayList<>(parent.processors()); + all.addAll(processors); return cachedProcessors = List.copyOf(all); } - - // ── Route listeners ────────────────────────────────────────────────────── - - /** Registers a boot-time {@link RouteListener}. Zero overhead on the request hot-path. */ - public void addRouteListener(RouteListener listener) { - routeListeners.add(listener); - cachedListeners = null; - } - - /** All route listeners visible from this context: parent-first, then own. Cached after first call. */ List routeListeners() { if (cachedListeners != null) return cachedListeners; - if (parent == null) return cachedListeners = List.copyOf(routeListeners); - List p = parent.routeListeners(); - if (routeListeners.isEmpty()) return cachedListeners = p; - List merged = new ArrayList<>(p.size() + routeListeners.size()); - merged.addAll(p); - merged.addAll(routeListeners); - return cachedListeners = List.copyOf(merged); + List all = parent == null ? new ArrayList<>() : new ArrayList<>(parent.routeListeners()); + all.addAll(routeListeners); return cachedListeners = List.copyOf(all); } -} \ No newline at end of file + + void resolveAll() { + if (state != State.DECLARING) throw new IllegalStateException("Service graph has already been closed"); + state = State.RESOLVING; + for (Binding binding : bindings.values()) resolveUnchecked(binding); + for (FlashContext child : children) child.resolveAll(); + state = State.READY; + } + void runReadyCallbacks() { + if (state != State.READY) throw new IllegalStateException("Service graph is not ready"); + for (Runnable callback : List.copyOf(readyCallbacks)) callback.run(); + readyCallbacks.clear(); + for (FlashContext child : children) child.runReadyCallbacks(); + } + + /** Completes graph resolution and runs all deferred materialisation callbacks once. */ + public void complete() { + resolveAll(); + runReadyCallbacks(); + } + + private void declare(Class type, Binding binding) { + requireDeclaring(); Objects.requireNonNull(type, "type"); + if (bindings.putIfAbsent(type, binding) != null) + throw new IllegalStateException("Duplicate provider declared for " + type.getName()); + } + private void requireDeclaring() { + if (state != State.DECLARING) throw new IllegalStateException("Flash service declarations are closed"); + } + @SuppressWarnings("unchecked") private void resolveUnchecked(Binding binding) { resolve((Binding) binding); } + private T resolve(Binding binding) { + if (binding.instance != null) return binding.instance; + if (binding.resolving) throw cycle(binding.type); + binding.resolving = true; resolutionPath.addLast(binding.type); + try { + for (Class dependency : binding.dependencies) require(dependency); + return binding.instance = Objects.requireNonNull(binding.factory.create(this), + () -> "Provider returned null for " + binding.type.getName()); + } finally { + resolutionPath.removeLast(); binding.resolving = false; + } + } + private IllegalStateException cycle(Class type) { + StringBuilder out = new StringBuilder("Circular service dependency: "); + for (Class node : resolutionPath) out.append(node.getSimpleName()).append(" -> "); + return new IllegalStateException(out.append(type.getSimpleName()).toString()); + } + private String dependencyTrace() { + return resolutionPath.isEmpty() ? "" : " (required while creating " + resolutionPath.peekLast().getName() + ')'; + } + private void verifyDeclaredDependency(Class type) { + if (resolutionPath.isEmpty()) return; + Class owner = resolutionPath.peekLast(); + Binding binding = bindings.get(owner); + if (binding != null && !binding.dependencies.contains(type)) + throw new IllegalStateException(owner.getName() + " requested undeclared dependency " + type.getName()); + } + + @FunctionalInterface public interface ServiceFactory { T create(FlashContext services); } + private static final class Binding { + final Class type; final List> dependencies; final ServiceFactory factory; + T instance; boolean resolving; + Binding(Class type, List> dependencies, ServiceFactory factory) { + this.type = type; this.dependencies = dependencies; this.factory = factory; + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashExtension.java b/flash/src/main/java/dev/relism/flash/extension/FlashExtension.java index e4765a2..d37d45a 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashExtension.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashExtension.java @@ -1,70 +1,17 @@ package dev.relism.flash.extension; /** - * Two-phase contract for all Flash extensions. + * One declarative contribution to a Flash application. * - *

Extension lifecycle inside {@link FlashApp#start()}: - *

    - *
  1. Extensions are sorted by {@link #priority()} — lower value runs first.
  2. - *
  3. Provide phase — {@link #provide(FlashContext)} is called for all - * installed extensions. Use this phase to register services, annotation processors, - * and route listeners. Never call {@link FlashContext#require} here.
  4. - *
  5. Context resolution — {@link FlashContext#resolveAll()} performs topological - * resolution of lazy suppliers. Circular or missing dependencies fail here with - * a clear message before any request is served.
  6. - *
  7. Routes phase — {@link #routes(FlashRegistrar, FlashContext)} is called for - * all extensions. All services are resolved; {@link FlashContext#require} is safe.
  8. - *
- * - *

Priority and middleware ordering

- * {@link #priority()} controls the order annotation processors are registered, which - * determines the annotation-layer middleware chain position: - *
- * Request ──► EARLY processors' mw ──► DEFAULT processors' mw ──► LATE processors' mw ──► handler
- * 
- * Use {@link ExtensionPhase} constants for semantic ordering: - *
{@code
- * @Override public int priority() { return ExtensionPhase.EARLY.value; }
- * }
- * - *

Example

- *
{@code
- * public class MetricsExtension implements FlashExtension {
- *
- *     @Override public int priority() { return ExtensionPhase.LATE.value; }
- *
- *     @Override
- *     public void provide(FlashContext ctx) {
- *         ctx.provide(MetricsRegistry.class, new PromMetricsRegistry());
- *     }
- *
- *     @Override
- *     public void routes(FlashRegistrar app, FlashContext ctx) {
- *         app.get("/metrics", (req, res) -> ctx.require(MetricsRegistry.class).scrape());
- *     }
- * }
- * }
+ *

Extensions never control lifecycle ordering. During {@link #configure}, they declare + * services, processors, listeners and ready callbacks. Flash closes declarations, validates and + * resolves the complete service graph, then executes ready callbacks to materialise routes. */ +@FunctionalInterface public interface FlashExtension { - /** - * Phase 1 — register services and processors. - * Safe: {@link FlashContext#provide}, {@link FlashContext#supply}, - * {@link FlashContext#addAnnotationProcessor}, {@link FlashContext#addRouteListener}. - * Unsafe: {@link FlashContext#require} (services not yet resolved). + * Declares this extension's contribution. {@code ctx.require(...)} is intentionally illegal + * here: work needing resolved services belongs in {@link FlashContext#onReady(Runnable)}. */ - default void provide(FlashContext ctx) {} - - /** - * Phase 2 — register routes. All services are fully resolved. - * {@link FlashContext#require} is safe here. - */ - default void routes(FlashRegistrar app, FlashContext ctx) {} - - /** - * Execution priority. Lower = earlier in the annotation middleware chain. - * Tie-breaking: same value → install order (sort is stable). - * Default: {@link ExtensionPhase#DEFAULT} (500). - */ - default int priority() { return ExtensionPhase.DEFAULT.value; } + void configure(FlashRegistrar app, FlashContext ctx); } diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashRegistrar.java b/flash/src/main/java/dev/relism/flash/extension/FlashRegistrar.java index 0818bb3..bd83f87 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashRegistrar.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashRegistrar.java @@ -6,11 +6,14 @@ import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.SimpleHandler; import dev.relism.flash.websocket.WebSocketEndpoint; import dev.relism.flash.routing.Middleware; +import dev.relism.flash.routing.MiddlewareKey; +import dev.relism.flash.routing.MiddlewareNode; import dev.relism.flash.routing.Route; import dev.relism.flash.routing.Routes; import dev.relism.flash.routing.Ws; import java.util.List; +import java.util.concurrent.atomic.AtomicLong; /** * Common route-registration surface shared by {@link FlashApp} and {@link FlashScope}. @@ -24,14 +27,15 @@ import java.util.List; * app.get("/admin", handler, oidc.requireRole("admin"), rateLimiter) * } * - *

{@link FlashExtension#routes} receives a {@code FlashRegistrar} for route - * registration. Extension installation ({@code install()}) is only available on + *

Extensions receive a {@link FlashApp} during their single configure declaration. + * Extension installation ({@code install()}) is only available on * {@link FlashApp} — scoped install is intentionally unsupported. * * @param concrete registrar type — enables fluent chaining without casting */ @SuppressWarnings("unchecked") public abstract class FlashRegistrar> { + private static final AtomicLong INLINE_KEYS = new AtomicLong(); // ── HTTP method registration ────────────────────────────────────────────── @@ -47,6 +51,18 @@ public abstract class FlashRegistrar> { public final SELF purge (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.PURGE, path, h, mw); } public final SELF query (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.QUERY, path, h, mw); } + public final SELF getWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.GET, path, h, mw); } + public final SELF postWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.POST, path, h, mw); } + public final SELF putWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.PUT, path, h, mw); } + public final SELF deleteWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.DELETE, path, h, mw); } + public final SELF patchWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.PATCH, path, h, mw); } + public final SELF optionsWith(String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.OPTIONS, path, h, mw); } + public final SELF headWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.HEAD, path, h, mw); } + public final SELF traceWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.TRACE, path, h, mw); } + public final SELF connectWith(String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.CONNECT, path, h, mw); } + public final SELF purgeWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.PURGE, path, h, mw); } + public final SELF queryWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.QUERY, path, h, mw); } + // ── Middleware ──────────────────────────────────────────────────────────── /** @@ -56,7 +72,13 @@ public abstract class FlashRegistrar> { * Order-independent: middleware is resolved at {@link FlashApp#start()}. */ public final SELF use(Middleware... middlewares) { - for (Middleware m : middlewares) addMiddleware(m); + for (Middleware m : middlewares) addMiddleware(inline(m)); + return (SELF) this; + } + + /** Adds named middleware nodes whose ordering constraints are compiled at boot. */ + public final SELF use(MiddlewareNode... middlewares) { + for (MiddlewareNode m : middlewares) addMiddleware(m); return (SELF) this; } @@ -93,18 +115,29 @@ public abstract class FlashRegistrar> { * Subclasses may prepend a namespace prefix and inject scope middlewares before storing. */ protected abstract void addRoute(HttpMethod method, String path, - RequestHandler handler, List mw); + RequestHandler handler, List mw); protected abstract void addWsRoute(String path, WebSocketEndpoint endpoint); /** Registers a middleware in this registrar's own scope (global or scope-level). */ - protected abstract void addMiddleware(Middleware mw); + protected abstract void addMiddleware(MiddlewareNode mw); private SELF route(HttpMethod method, String path, SimpleHandler.FunctionalHandler h, Middleware[] mw) { + MiddlewareNode[] nodes = new MiddlewareNode[mw.length]; + for (int i = 0; i < mw.length; i++) nodes[i] = inline(mw[i]); + addRoute(method, path, new SimpleHandler(h), mw.length == 0 ? List.of() : List.of(nodes)); + return (SELF) this; + } + + private SELF route(HttpMethod method, String path, SimpleHandler.FunctionalHandler h, MiddlewareNode[] mw) { addRoute(method, path, new SimpleHandler(h), mw.length == 0 ? List.of() : List.of(mw)); return (SELF) this; } + private static MiddlewareNode inline(Middleware middleware) { + return MiddlewareNode.of(MiddlewareKey.of("flash.inline." + INLINE_KEYS.incrementAndGet()), middleware); + } + protected static RequestHandler instantiate(Class cls) { try { return (RequestHandler) cls.getDeclaredConstructor().newInstance(); } catch (Exception e) { diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashScope.java b/flash/src/main/java/dev/relism/flash/extension/FlashScope.java index 071142b..f3a117a 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashScope.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashScope.java @@ -3,7 +3,7 @@ package dev.relism.flash.extension; import dev.relism.flash.http.HttpMethod; import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.SimpleHandler; -import dev.relism.flash.routing.Middleware; +import dev.relism.flash.routing.MiddlewareNode; import dev.relism.flash.routing.PathUtils; import dev.relism.flash.websocket.WebSocketEndpoint; @@ -28,7 +28,7 @@ public final class FlashScope extends FlashRegistrar { private final String namespace; private final FlashContext ctx; - private final List scopeMiddlewares = new ArrayList<>(); + private final List scopeMiddlewares = new ArrayList<>(); private final List deferredRoutes = new ArrayList<>(); private final List deferredWsRoutes = new ArrayList<>(); @@ -44,8 +44,8 @@ public final class FlashScope extends FlashRegistrar { // ── FlashRegistrar impl ─────────────────────────────────────────────────── @Override - protected void addRoute(HttpMethod method, String path, RequestHandler handler, List mw) { - List scopeMw = scopeMiddlewares.isEmpty() ? List.of() : List.copyOf(scopeMiddlewares); + protected void addRoute(HttpMethod method, String path, RequestHandler handler, List mw) { + List scopeMw = scopeMiddlewares.isEmpty() ? List.of() : List.copyOf(scopeMiddlewares); deferredRoutes.add(new RouteDefinition( method, ns(path), handler, scopeMw, mw, !(handler instanceof SimpleHandler), ctx, namespace)); @@ -57,7 +57,7 @@ public final class FlashScope extends FlashRegistrar { } @Override - protected void addMiddleware(Middleware mw) { scopeMiddlewares.add(mw); } + protected void addMiddleware(MiddlewareNode mw) { scopeMiddlewares.add(mw); } // ── Internal (called by FlashApp.mount) ─────────────────────────────────── diff --git a/flash/src/main/java/dev/relism/flash/extension/RouteDefinition.java b/flash/src/main/java/dev/relism/flash/extension/RouteDefinition.java index 90db000..11527ca 100644 --- a/flash/src/main/java/dev/relism/flash/extension/RouteDefinition.java +++ b/flash/src/main/java/dev/relism/flash/extension/RouteDefinition.java @@ -2,7 +2,7 @@ package dev.relism.flash.extension; import dev.relism.flash.http.HttpMethod; import dev.relism.flash.models.RequestHandler; -import dev.relism.flash.routing.Middleware; +import dev.relism.flash.routing.MiddlewareNode; import java.util.List; @@ -26,8 +26,8 @@ record RouteDefinition( HttpMethod method, String path, RequestHandler handler, - List scopeMiddlewares, - List explicitMiddlewares, + List scopeMiddlewares, + List explicitMiddlewares, boolean classBasedHandler, FlashContext ctx, String namespace diff --git a/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java b/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java index a54db16..1ccd6d9 100644 --- a/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java +++ b/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java @@ -32,6 +32,9 @@ import java.nio.charset.StandardCharsets; */ public abstract class AbstractRouter { + /** Eagerly validates and compiles this route graph before traffic is accepted. */ + public void compile() {} + // Pre-encoded prod JSON error bodies — zero allocation on error paths. private static final byte[] JSON_404 = "{\"error\":\"Not Found\",\"status\":404}" .getBytes(StandardCharsets.UTF_8); diff --git a/flash/src/main/java/dev/relism/flash/routing/AbstractWsRouter.java b/flash/src/main/java/dev/relism/flash/routing/AbstractWsRouter.java index a4bb146..6b78755 100644 --- a/flash/src/main/java/dev/relism/flash/routing/AbstractWsRouter.java +++ b/flash/src/main/java/dev/relism/flash/routing/AbstractWsRouter.java @@ -8,6 +8,9 @@ import dev.relism.flash.websocket.WebSocketHandler; public abstract class AbstractWsRouter { + /** Eagerly validates and compiles this WebSocket route graph before traffic is accepted. */ + public void compile() {} + public final AbstractWsRouter register(HttpMethod method, String path, WebSocketHandler handler) { return addRoute(method, PathUtils.sanitize(path), handler); } diff --git a/flash/src/main/java/dev/relism/flash/routing/MiddlewareGraph.java b/flash/src/main/java/dev/relism/flash/routing/MiddlewareGraph.java new file mode 100644 index 0000000..99f887d --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/routing/MiddlewareGraph.java @@ -0,0 +1,55 @@ +package dev.relism.flash.routing; + +import dev.relism.flash.models.RequestHandler; +import dev.relism.flash.models.SimpleHandler; + +import java.util.*; + +/** Boot-only DAG compiler for a route's middleware nodes. */ +public final class MiddlewareGraph { + private MiddlewareGraph() {} + + public static Middleware[] order(String route, List nodes) { + if (nodes.isEmpty()) return new Middleware[0]; + Map index = new LinkedHashMap<>(); + for (int i = 0; i < nodes.size(); i++) { + MiddlewareKey key = nodes.get(i).key(); + if (index.putIfAbsent(key, i) != null) + throw new IllegalStateException("Duplicate middleware " + key.value() + " on " + route); + } + List> outgoing = new ArrayList<>(nodes.size()); + int[] incoming = new int[nodes.size()]; + for (int i = 0; i < nodes.size(); i++) outgoing.add(new LinkedHashSet<>()); + for (int source = 0; source < nodes.size(); source++) { + for (MiddlewareNode.Constraint c : nodes.get(source).constraints()) { + Integer target = index.get(c.target()); + if (target == null) { + if (c.required()) throw new IllegalStateException("Middleware " + nodes.get(source).key().value() + + " on " + route + " requires " + c.target().value() + " to be present"); + continue; + } + int from = c.relation() == MiddlewareNode.Relation.AFTER ? target : source; + int to = c.relation() == MiddlewareNode.Relation.AFTER ? source : target; + if (outgoing.get(from).add(to)) incoming[to]++; + } + } + PriorityQueue ready = new PriorityQueue<>(); + for (int i = 0; i < incoming.length; i++) if (incoming[i] == 0) ready.add(i); + Middleware[] ordered = new Middleware[nodes.size()]; + int out = 0; + while (!ready.isEmpty()) { + int current = ready.remove(); + ordered[out++] = nodes.get(current).middleware(); + for (int next : outgoing.get(current)) if (--incoming[next] == 0) ready.add(next); + } + if (out != nodes.size()) throw new IllegalStateException("Middleware ordering cycle on " + route); + return ordered; + } + + /** Pre-composes a sorted chain once at boot. */ + public static RequestHandler compose(RequestHandler handler, Middleware[] ordered) { + RequestHandler current = handler; + for (int i = ordered.length - 1; i >= 0; i--) current = new SimpleHandler(ordered[i].wrap(current)); + return current; + } +} diff --git a/flash/src/main/java/dev/relism/flash/routing/MiddlewareKey.java b/flash/src/main/java/dev/relism/flash/routing/MiddlewareKey.java new file mode 100644 index 0000000..e85cc4e --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/routing/MiddlewareKey.java @@ -0,0 +1,11 @@ +package dev.relism.flash.routing; + +import java.util.Objects; + +/** Stable boot-time identity of a middleware node. Never consulted while handling a request. */ +public record MiddlewareKey(String value) { + public MiddlewareKey { + if (value == null || value.isBlank()) throw new IllegalArgumentException("Middleware key cannot be blank"); + } + public static MiddlewareKey of(String value) { return new MiddlewareKey(value); } +} diff --git a/flash/src/main/java/dev/relism/flash/routing/MiddlewareNode.java b/flash/src/main/java/dev/relism/flash/routing/MiddlewareNode.java new file mode 100644 index 0000000..a05b3c0 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/routing/MiddlewareNode.java @@ -0,0 +1,35 @@ +package dev.relism.flash.routing; + +import java.util.*; + +/** + * A named middleware contribution and its ordering constraints. + * + *

Constraints are resolved only while Flash compiles a route. The resulting handler chain + * contains no keys, graphs, ordering checks or additional request-path allocations. + */ +public final class MiddlewareNode { + private final MiddlewareKey key; + private final Middleware middleware; + private final List constraints = new ArrayList<>(); + + private MiddlewareNode(MiddlewareKey key, Middleware middleware) { + this.key = Objects.requireNonNull(key, "key"); + this.middleware = Objects.requireNonNull(middleware, "middleware"); + } + + public static MiddlewareNode of(MiddlewareKey key, Middleware middleware) { return new MiddlewareNode(key, middleware); } + public MiddlewareNode after(MiddlewareKey key) { constraints.add(new Constraint(key, Relation.AFTER, true)); return this; } + public MiddlewareNode afterIfPresent(MiddlewareKey key) { constraints.add(new Constraint(key, Relation.AFTER, false)); return this; } + public MiddlewareNode before(MiddlewareKey key) { constraints.add(new Constraint(key, Relation.BEFORE, true)); return this; } + public MiddlewareNode beforeIfPresent(MiddlewareKey key) { constraints.add(new Constraint(key, Relation.BEFORE, false)); return this; } + + public MiddlewareKey key() { return key; } + Middleware middleware() { return middleware; } + List constraints() { return List.copyOf(constraints); } + + enum Relation { BEFORE, AFTER } + record Constraint(MiddlewareKey target, Relation relation, boolean required) { + Constraint { Objects.requireNonNull(target, "target"); } + } +} diff --git a/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java b/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java index 7697a9e..6041521 100644 --- a/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java +++ b/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java @@ -92,4 +92,7 @@ public class FastPathRouterImpl extends AbstractRouter { } } } + + @Override + public void compile() { ensureCompiled(); } } diff --git a/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathWsRouterImpl.java b/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathWsRouterImpl.java index fcd7442..339fe4d 100644 --- a/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathWsRouterImpl.java +++ b/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathWsRouterImpl.java @@ -56,6 +56,9 @@ public final class FastPathWsRouterImpl extends AbstractWsRouter { } } + @Override + public void compile() { ensureCompiled(); } + private static final class Context { private static final ThreadLocal> RESULT = ThreadLocal.withInitial(() -> new MatchResult<>(32, 128)); diff --git a/flash/src/test/java/dev/relism/flash/extension/FlashContextTest.java b/flash/src/test/java/dev/relism/flash/extension/FlashContextTest.java new file mode 100644 index 0000000..ff1662e --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/extension/FlashContextTest.java @@ -0,0 +1,46 @@ +package dev.relism.flash.extension; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class FlashContextTest { + + @Test + void resolvesDeclaredGraphRegardlessOfDeclarationOrder() { + FlashContext ctx = new FlashContext(); + ctx.supply(Service.class, Dependency.class, Service::new); + ctx.provide(Dependency.class, new Dependency()); + + ctx.resolveAll(); + + assertNotNull(ctx.require(Service.class).dependency); + } + + @Test + void rejectsCircularDeclaredGraphBeforeReadyCallbacks() { + FlashContext ctx = new FlashContext(); + ctx.supply(Left.class, services -> new Left(), Right.class); + ctx.supply(Right.class, services -> new Right(), Left.class); + + IllegalStateException error = assertThrows(IllegalStateException.class, ctx::resolveAll); + + assertEquals("Circular service dependency: Left -> Right -> Left", error.getMessage()); + } + + @Test + void rejectsAFactoryLookupThatWasNotDeclared() { + FlashContext ctx = new FlashContext(); + ctx.provide(Dependency.class, new Dependency()); + ctx.supply(Service.class, services -> new Service(services.require(Dependency.class))); + + IllegalStateException error = assertThrows(IllegalStateException.class, ctx::resolveAll); + + assertTrue(error.getMessage().contains("undeclared dependency")); + } + + private static final class Dependency {} + private static final class Service { final Dependency dependency; Service(Dependency dependency) { this.dependency = dependency; } } + private static final class Left {} + private static final class Right {} +} diff --git a/flash/src/test/java/dev/relism/flash/routing/MiddlewareGraphTest.java b/flash/src/test/java/dev/relism/flash/routing/MiddlewareGraphTest.java new file mode 100644 index 0000000..7d74b79 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/routing/MiddlewareGraphTest.java @@ -0,0 +1,42 @@ +package dev.relism.flash.routing; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class MiddlewareGraphTest { + private static final Middleware NOOP = next -> next::handle; + + @Test + void ordersNodesFromConstraintsNotRegistrationOrder() { + MiddlewareKey auth = MiddlewareKey.of("auth"); + MiddlewareKey audit = MiddlewareKey.of("audit"); + Middleware authMiddleware = next -> next::handle; + Middleware auditMiddleware = next -> next::handle; + MiddlewareNode auditNode = MiddlewareNode.of(audit, auditMiddleware).after(auth); + MiddlewareNode authNode = MiddlewareNode.of(auth, authMiddleware); + + Middleware[] ordered = MiddlewareGraph.order("GET /", List.of(auditNode, authNode)); + + assertSame(authMiddleware, ordered[0]); + assertSame(auditMiddleware, ordered[1]); + } + + @Test + void rejectsAbsentRequiredPredecessor() { + MiddlewareNode audit = MiddlewareNode.of(MiddlewareKey.of("audit"), NOOP) + .after(MiddlewareKey.of("auth")); + + assertThrows(IllegalStateException.class, () -> MiddlewareGraph.order("GET /", List.of(audit))); + } + + @Test + void acceptsAbsentOptionalPredecessor() { + MiddlewareNode audit = MiddlewareNode.of(MiddlewareKey.of("audit"), NOOP) + .afterIfPresent(MiddlewareKey.of("auth")); + + assertEquals(1, MiddlewareGraph.order("GET /", List.of(audit)).length); + } +}