From 829b9bf34873e768e85a41a9a092e67bbba33ae5 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Thu, 10 Sep 2026 19:12:44 +0000 Subject: [PATCH] feat(ext-mcp): let applications put middleware on the MCP route, and document the auth split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit McpExtension built its middleware chain entirely internally, so a consumer had no way to add rate limiting, audit logging or tracing to /mcp — routine on every other Flash route. McpConfig.middleware(...) appends to the chain after the transport guards and after whatever McpSecurity resolved to, so it composes with OAuth2 protection instead of replacing it, and never satisfies REQUIRED. Docs: flash-ext-auth-core and flash-ext-auth-oidc both get a docs/ directory — oidc had none at all, and its module README documented types that no longer exist. Includes a migration table from flash-ext-oidc. --- README.md | 4 +- .../flash-ext-auth-core/docs/README.md | 105 ++++++++++++++++++ .../docs/credential-sources.md | 95 ++++++++++++++++ .../flash-ext-auth-core/docs/sessions.md | 49 ++++++++ .../flash-ext-auth-oidc/README.md | 86 ++++++++------ .../flash-ext-auth-oidc/docs/README.md | 98 ++++++++++++++++ .../flash-ext-auth-oidc/docs/interop.md | 88 +++++++++++++++ .../flash-ext-mcp/docs/security.md | 18 +++ .../dev/relism/flash/ext/mcp/McpConfig.java | 22 ++++ .../relism/flash/ext/mcp/McpExtension.java | 3 +- .../ext/mcp/McpConfigMiddlewareTest.java | 61 ++++++++++ 11 files changed, 590 insertions(+), 39 deletions(-) create mode 100644 flash-extensions/flash-ext-auth-core/docs/README.md create mode 100644 flash-extensions/flash-ext-auth-core/docs/credential-sources.md create mode 100644 flash-extensions/flash-ext-auth-core/docs/sessions.md create mode 100644 flash-extensions/flash-ext-auth-oidc/docs/README.md create mode 100644 flash-extensions/flash-ext-auth-oidc/docs/interop.md create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java diff --git a/README.md b/README.md index 89fc701..6fa1051 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res | `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses | | `flash-extensions/flash-ext-jackson` | Jackson JSON integration | | `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI | +| `flash-extensions/flash-ext-auth-core` | Authentication seam + role/scope authorization | | `flash-extensions/flash-ext-auth-oidc` | OIDC Authorization Code + PKCE flow | | `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-auth-oidc | | `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives | @@ -146,7 +147,8 @@ FlashApp.create(8080) See extension-specific READMEs for full details: - [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md) - [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md) -- [`flash-ext-auth-oidc`](flash-extensions/flash-ext-auth-oidc/README.md) +- [`flash-ext-auth-core`](flash-extensions/flash-ext-auth-core/docs/README.md) +- [`flash-ext-auth-oidc`](flash-extensions/flash-ext-auth-oidc/docs/README.md) - [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md) - [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md) - [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md) diff --git a/flash-extensions/flash-ext-auth-core/docs/README.md b/flash-extensions/flash-ext-auth-core/docs/README.md new file mode 100644 index 0000000..be13f11 --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/docs/README.md @@ -0,0 +1,105 @@ +# flash-ext-auth-core + +Authorization, and the plumbing that carries a caller's identity through a request. It does not +know how anyone signed in — that is a `CredentialSource`, and `flash-ext-auth-oidc` ships the +OpenID Connect one. + +The split follows the same shape as `flash-ext-cache-core`/`-caffeine` and +`flash-ext-data-core`/`-hibernate`: the abstract half here, the implementations beside it. + +## The model + +``` +request ──► CredentialSource.authenticate(req, res) ──► claims + │ + ClaimsHolder.set (this module only) + │ + AuthMiddleware matches roles / scopes + │ + handler +``` + +| Type | What it is | +|---|---| +| `CredentialSource` | Turns what a request carries into claims, or rejects it. One per mechanism. | +| `AuthMiddleware` | Publishes the claims, enforces `@RolesAllowed`/`@ScopesAllowed`, clears up. | +| `ClaimsHolder` | The current request's claims. Read from anywhere; written only from here. | +| `Claims` | Typed view over a claims map — `sub()`, `email()`, `roles(path)`, `scopes()`. | +| `AuthPolicy` | What a handler's annotations compiled to, resolved once at boot. | +| `Session`, `SessionStore` | Server-side sessions for sources that keep them. | + +Nothing outside this module can write `ClaimsHolder`. A source *returns* claims and the middleware +publishes them, so no code can put claims on a request that did not carry them. + +## Using it + +You rarely install this module directly — an extension that contributes a source does it for you: + +```java +// inside your extension's configure(...) +AuthMiddleware auth = AuthMiddleware.install(ctx, AuthConfig.builder() + .rolesClaimPath("realm_access.roles") + .scopeClaimPaths("scope,scp") + .build(), mySource); +``` + +`install` publishes the middleware in the context and registers the annotation processor, so every +scanned handler carrying an auth annotation is mounted behind it. See +[`credential-sources.md`](credential-sources.md) to write a source of your own. + +On lambda routes, take the middleware out of the context: + +```java +AuthMiddleware auth = app.ctx().require(AuthMiddleware.class); + +app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect()); +app.get("/", homeHandler, auth.optional()); +app.delete("/admin/users/{id}", deleteHandler, auth.requireRole("admin")); +app.post("/orders", createOrder, auth.requireScopes("orders:write")); +``` + +## Annotations + +On a scanned handler class, and mounted automatically: + +| Annotation | Effect | +|---|---| +| `@Authenticated` | Any accepted credential. No role check. | +| `@Authenticated(optional = true)` | Never rejects; publishes claims when there are some. | +| `@RolesAllowed({"a","b"})` | Authenticated **and** holding at least one of the roles. | +| `@ScopesAllowed({"x","y"})` | Authenticated **and** holding all of the scopes. | +| `@ScopesAllowed(value = {...}, match = ANY)` | …at least one of them. | + +`@Authenticated(optional = true)` cannot be combined with a role or scope requirement — asking for +a role on a route that admits anonymous callers is a contradiction, and it fails at boot rather +than at 3am. + +## Where roles and scopes are read from + +`AuthConfig` names the claim paths, because every provider spells them differently: + +| | Default | Common alternatives | +|---|---|---| +| `rolesClaimPath` | `roles` | `realm_access.roles` (Keycloak), `groups` (Authelia) | +| `scopeClaimPaths` | `scope,scp` | plus e.g. `permissions.scopes` | + +Paths are dot-separated and walk nested maps. Scope paths are a comma-separated list tried in +order, so a token that puts scopes in `scp` and a legacy one that uses `scope` both work. + +Matching is deliberate about a distinction that bites otherwise: + +- a **string** claim is split on spaces, tabs, newlines and commas — `"openid orders:read"` is two + scopes; +- a **list** claim is compared entry by entry, whole and trimmed — `["a b"]` is one role named + `a b`, not two. + +Prefix matches never count: `administrator` does not satisfy `admin`. + +## Ordering around authentication + +`AuthMiddleware.POLICY` is the boot-time key the annotation-driven node mounts under. An extension +contributing its own middleware can order itself against it: + +```java +MiddlewareNode.of(MY_KEY, myMiddleware).afterIfPresent(AuthMiddleware.POLICY); +``` diff --git a/flash-extensions/flash-ext-auth-core/docs/credential-sources.md b/flash-extensions/flash-ext-auth-core/docs/credential-sources.md new file mode 100644 index 0000000..efabb8c --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/docs/credential-sources.md @@ -0,0 +1,95 @@ +# Writing a credential source + +A `CredentialSource` is the only thing that stands between a request and its claims. Everything +else in this module — annotations, policy, matching, the holder — works the same regardless of +which one is installed. + +```java +public interface CredentialSource { + Map authenticate(Request req, Response res); + Map peek(Request req); + default String insufficientScopeChallenge(String[] requiredScopes) { return null; } +} +``` + +## `authenticate` has three outcomes, and the last two are not the same + +| Return | Means | The middleware then | +|---|---|---| +| claims | a valid credential was presented | publishes them and calls the handler | +| `null` | **no** credential, and the source has already answered the request | stops, writes nothing more | +| throws `HttpException` | a credential **was** presented and is invalid | propagates it | + +Flattening the last two is the single easiest way to get this wrong. "No session, send the browser +to the sign-in page" and "this token is forged" are different answers, and a caller can tell: +the first is a `302` to a login screen, the second a `401` the client must not retry blindly. + +A source that returns `null` owns the response by then — it has redirected, or written a `401` with +its own `WWW-Authenticate` header. A source that throws sets any challenge header it owes *before* +throwing, because the exception unwinds past the middleware. + +`peek` is the same resolution with every rejection removed: no throwing, no redirecting, `null` +when there is nothing valid. It backs `@Authenticated(optional = true)`, where an anonymous caller +is a normal outcome. Never make `peek` refresh state that `authenticate` would not have. + +## A minimal source + +```java +public final class ApiKeySource implements CredentialSource { + + private final Map> keys; // key -> claims + + @Override + public Map authenticate(Request req, Response res) { + String key = req.header("X-Api-Key"); + if (key == null) { + res.header("WWW-Authenticate", "ApiKey realm=\"api\""); + throw HttpException.unauthorized(); + } + Map claims = keys.get(key); + if (claims == null) throw HttpException.unauthorized(); // presented and wrong + return claims; + } + + @Override + public Map peek(Request req) { + String key = req.header("X-Api-Key"); + return key != null ? keys.get(key) : null; + } +} +``` + +This one never returns `null` from `authenticate` — it has no sign-in flow to redirect into, so +"absent" and "invalid" both mean `401`. That is a legitimate shape; the three outcomes are what +the interface *allows*, not a checklist. + +## Claims are yours to shape + +The claims map is whatever your mechanism produces. `Claims` reads a few conventional keys — +`sub`, `email`, `name`, `preferred_username` — so populating those makes your source work with +code written against any other. Roles and scopes are read from wherever `AuthConfig` points, so +they can live under any key you like as long as the two agree. + +## Installing it + +```java +public final class ApiKeyExtension implements FlashExtension { + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + ctx.provide(ApiKeySource.class, source); + AuthMiddleware.install(ctx, AuthConfig.builder() + .rolesClaimPath("roles") + .build(), source); + } +} +``` + +`AuthMiddleware.install` also registers the annotation processor, so scanned handlers carrying +`@Authenticated` and friends are mounted behind your source with nothing further to do. + +## One source at a time + +`AuthMiddleware` is published in the context under its own type, so installing two extensions that +each call `install` leaves the last one winning — quietly. If an app genuinely needs to accept two +kinds of credential, that is one source that tries both, not two sources: the order they are tried +in, and what happens when the first rejects, are decisions that have to live somewhere explicit. diff --git a/flash-extensions/flash-ext-auth-core/docs/sessions.md b/flash-extensions/flash-ext-auth-core/docs/sessions.md new file mode 100644 index 0000000..e17fb67 --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/docs/sessions.md @@ -0,0 +1,49 @@ +# Sessions + +A `Session` is what a credential source keeps server-side between requests, looked up by a cookie. +Core owns the container; what goes in it is the source's business. + +```java +public final class Session { + String id(); + Map claims(); + Instant expiresAt(); + Map attributes(); + boolean isExpired(); + Object attribute(String key); + String attributeAsString(String key); +} +``` + +## Why it expires early + +`isExpired()` returns true **30 seconds before** `expiresAt`. Without that window a session can +pass the check at the top of a request and be dead by the time the handler uses it — a class of +failure that reproduces once a day and never in a test. Renewal is therefore always slightly +premature, on purpose. + +## Attributes + +`attributes()` is opaque to this module. `flash-ext-auth-oidc` keeps its access, id and refresh +tokens there under its own keys, which is what lets renewal stay entirely inside that extension +while the session itself carries no OAuth2 vocabulary. + +Store what your source needs to renew or revoke, and nothing a handler should be reading — handlers +read `claims()`. + +## The store + +```java +public interface SessionStore { + void save(Session session); + Optional find(String sessionId); + void delete(String sessionId); +} +``` + +`InMemorySessionStore` is the default: a `ConcurrentHashMap`, fine for a single instance, and it +loses every session on restart. Supply your own for Redis or JDBC when sessions have to survive a +deploy or be shared across nodes. + +Sessions are immutable. Renewing one builds a new instance with the same `id()` and `save`s it +over the old — there is no mutate-in-place path, so a store can cache or serialise freely. diff --git a/flash-extensions/flash-ext-auth-oidc/README.md b/flash-extensions/flash-ext-auth-oidc/README.md index 5fccedf..960d20e 100644 --- a/flash-extensions/flash-ext-auth-oidc/README.md +++ b/flash-extensions/flash-ext-auth-oidc/README.md @@ -6,6 +6,11 @@ Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider. Standards alignment focuses on OIDC Core + OAuth2 bearer APIs while preserving Flash's hot-path model (middleware compiled at mount time, no heavy runtime work). +This extension is the OpenID Connect **credential source** for +[`flash-ext-auth-core`](../flash-ext-auth-core/docs/README.md), which owns everything downstream of +identifying the caller. Shorter guides live in [`docs/`](docs/README.md), including +[migration notes](docs/interop.md#migrating-from-flash-ext-oidc) from `flash-ext-oidc`. + ## What it provides | Component | Description | @@ -13,24 +18,25 @@ hot-path model (middleware compiled at mount time, no heavy runtime work). | `GET {prefix}/login` | Starts the OIDC flow: builds the authorization URL with PKCE + state, redirects | | `GET {prefix}/callback` | Exchanges the code, validates the ID token, creates a session, redirects | | `POST {prefix}/logout` | Invalidates the session, redirects to the provider's `end_session_endpoint` | -| `@Authenticated` | Annotation: protects a class-based handler (redirects browsers, 401 for API clients) | -| `@RolesAllowed(...)` | Annotation: protects with role check (OR semantics) | -| `@ScopesAllowed(...)` | Annotation: protects with scope check (`ALL` default, `ANY` optional) | -| `OidcMiddleware` | Programmatic middleware for lambda routes | -| `ClaimsHolder` / `OidcUser` | Thread-local user info accessible from any protected handler | +| `OidcCredentialSource` | The `CredentialSource` this extension contributes to `flash-ext-auth-core` | | `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) | +`@Authenticated`, `@RolesAllowed`, `@ScopesAllowed`, `AuthMiddleware`, `ClaimsHolder` and `Claims` +belong to [`flash-ext-auth-core`](../flash-ext-auth-core/docs/README.md) and work the same behind +any credential source. Installing this extension brings them in and wires them up — you do not +install auth-core yourself. + ## Dependencies ```xml dev.relism flash-ext-auth-oidc - 1.0-SNAPSHOT + 2.1.0-SNAPSHOT ``` -Transitive: `nimbus-jose-jwt`, `json-smart`. +Transitive: `flash-ext-auth-core`, `nimbus-jose-jwt`, `json-smart`. Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically. ## Installation @@ -98,7 +104,7 @@ OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/cal | `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes | | `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation | | `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout | -| `.sessionStore(store)` | `InMemoryOidcSessionStore` | Custom session store (see below) | +| `.sessionStore(store)` | `InMemorySessionStore` | Custom session store (see below) | | `.clientAuthMethod(ClientAuthMethod.POST)` | `POST` | `POST` = credentials in body; `BASIC` = `Authorization: Basic` | | `.insecureTls()` | `false` | Disables TLS certificate verification — **development only** | | `.schemeName("myscheme")` | derived from issuer | OpenAPI security scheme name | @@ -130,7 +136,7 @@ OIDC_CLIENT_AUTH_METHOD default: POST public class MePage extends JacksonHandler { @Override public Object handle(Request req, Response res) { - OidcUser u = ClaimsHolder.user(); + Claims u = ClaimsHolder.current(); return json(res, Map.of("sub", u.sub(), "email", u.email())); } } @@ -166,49 +172,50 @@ Annotation composition rules: ### Lambda routes (manual middleware) -For lambda routes, pass the middleware as a varargs argument. Retrieve `OidcMiddleware` +For lambda routes, pass the middleware as a varargs argument. Retrieve `AuthMiddleware` from the context inside another extension's `routes()` phase, or after `start()`: ```java -OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class); +AuthMiddleware auth = app.ctx().require(AuthMiddleware.class); // Authentication only app.get("/api/me", (req, res) -> { - OidcUser u = ClaimsHolder.user(); // never null here + Claims u = ClaimsHolder.current(); // never null here return Map.of("sub", u.sub(), "email", u.email()); -}, oidc.protect()); +}, auth.protect()); // Authentication + role check app.delete("/api/admin/users/{id}", (req, res) -> { - OidcUser u = ClaimsHolder.user(); + Claims u = ClaimsHolder.current(); // ... -}, oidc.requireRole("admin")); +}, auth.requireRole("admin")); // Multiple roles (OR): passes if user holds any one of them -app.get("/api/reports", (req, res) -> { ... }, oidc.requireRole("admin", "reports-viewer")); +app.get("/api/reports", (req, res) -> { ... }, auth.requireRole("admin", "reports-viewer")); // Require all listed scopes -app.post("/api/orders", (req, res) -> { ... }, oidc.requireScopes("orders:write", "payments:write")); +app.post("/api/orders", (req, res) -> { ... }, auth.requireScopes("orders:write", "payments:write")); // Require at least one listed scope -app.post("/api/payments", (req, res) -> { ... }, oidc.requireAnyScope("payments:write", "payments:admin")); +app.post("/api/payments", (req, res) -> { ... }, auth.requireAnyScope("payments:write", "payments:admin")); ``` -`oidc.protect()` / `oidc.requireRole(...)` / `oidc.requireScopes(...)` return a `Middleware` — a composable -`Handler → Handler` wrapper. Flash applies middleware right-to-left so the OIDC check +`auth.protect()` / `auth.requireRole(...)` / `auth.requireScopes(...)` return a `Middleware` — a composable +`Handler → Handler` wrapper. Flash applies middleware right-to-left so the authentication check runs before your handler. ## Accessing the authenticated user `ClaimsHolder` holds the JWT claims for the current request in a `ThreadLocal`. -It is populated by the OIDC middleware before your handler runs and cleared in the -`finally` block afterward. It is safe with virtual threads (each request gets its +It is populated by `AuthMiddleware` — from `flash-ext-auth-core` — once this source has +authenticated the request, and cleared in the `finally` block afterward. Nothing outside that +module can write to it. It is safe with virtual threads (each request gets its own virtual thread, so `ThreadLocal` values are naturally isolated). -### OidcUser (preferred) +### Claims (preferred) ```java -OidcUser u = ClaimsHolder.user(); // never null inside a protected handler +Claims u = ClaimsHolder.current(); // never null inside a protected handler String sub = u.sub(); // unique user ID String email = u.email(); @@ -241,7 +248,7 @@ Map all = u.claims(); ### Raw access (escape hatch) ```java -Map claims = ClaimsHolder.get(); +Map claims = ClaimsHolder.map(); String email = ClaimsHolder.claim("email"); ``` @@ -340,7 +347,7 @@ Quick path to test `@ScopesAllowed` end-to-end: - Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"` 5. **Protect a handler** - `@ScopesAllowed("orders:write")` on class-based handlers - - or `oidc.requireScopes("orders:write")` for lambda routes + - or `auth.requireScopes("orders:write")` for lambda routes 6. **Verify behavior** - token with scope -> 200 - token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope` @@ -354,24 +361,29 @@ Useful token inspection flow while testing: ## Session store -The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments. -For clustered deployments, implement `OidcSessionStore`: +Sessions live in `flash-ext-auth-core`'s `Session`/`SessionStore`; this extension keeps its +access, id and refresh tokens in `Session.attributes()` under its own keys, so renewal stays here +and core carries no OAuth2 vocabulary. See +[`../flash-ext-auth-core/docs/sessions.md`](../flash-ext-auth-core/docs/sessions.md). + +The default `InMemorySessionStore` is sufficient for single-instance deployments. +For clustered deployments, implement `SessionStore`: ```java -public interface OidcSessionStore { - void save(OidcSession session); - Optional find(String sessionId); +public interface SessionStore { + void save(Session session); + Optional find(String sessionId); void delete(String sessionId); } ``` ```java OidcConfig.builder(...) - .sessionStore(new RedisOidcSessionStore(redisClient)) + .sessionStore(new RedisSessionStore(redisClient)) .build() ``` -`OidcSession` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map). +`Session` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map). ## Logout @@ -401,7 +413,7 @@ Authorization: Bearer ``` The token must be a JWT (opaque tokens are not supported). Claims are available via -`ClaimsHolder.user()` as usual. +`ClaimsHolder.current()` as usual. ## Multi-tenant @@ -420,7 +432,7 @@ app.install(new OidcExtension(tenantA)) ``` To reference a specific tenant's middleware on lambda routes, keep the extension instances -and retrieve `OidcMiddleware` from context after `start()`: +and retrieve `AuthMiddleware` from context after `start()`: ```java OidcExtension extA = new OidcExtension(tenantA); @@ -432,10 +444,10 @@ FlashApp app = FlashApp.create(8080) .start() .join(); // wait for bind -OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // last registered = tenantB +AuthMiddleware mwA = app.ctx().require(AuthMiddleware.class); // last registered = tenantB ``` -> **Note:** because both extensions register `OidcMiddleware.class` in the same context, +> **Note:** because both extensions register `AuthMiddleware.class` in the same context, > only the last one wins under that key. For multi-tenant setups, use distinct context > keys or provide middleware under a wrapper/alias type, or use lambda routes with explicit > middleware captured from the extension instance before `install()`. diff --git a/flash-extensions/flash-ext-auth-oidc/docs/README.md b/flash-extensions/flash-ext-auth-oidc/docs/README.md new file mode 100644 index 0000000..5a64011 --- /dev/null +++ b/flash-extensions/flash-ext-auth-oidc/docs/README.md @@ -0,0 +1,98 @@ +# flash-ext-auth-oidc + +OpenID Connect for Flash: the authorization-code flow with PKCE, JWKS-validated bearer tokens, +server-side sessions with silent refresh, and single logout. + +It is a **credential source** for [`flash-ext-auth-core`](../../flash-ext-auth-core/docs/README.md), +which owns everything downstream of "who is this caller" — `@Authenticated`, `@RolesAllowed`, +`@ScopesAllowed`, `ClaimsHolder`. Installing this extension installs that machinery too; you do not +install `flash-ext-auth-core` yourself. + +## Quick start + +```java +app.install(new OidcExtension( + OidcConfig.builder( + "https://keycloak.example.com/realms/myrealm", + "my-app", "secret", "/auth/callback") + .rolesClaimPath("realm_access.roles") + .https() + .build())); +``` + +That is the whole integration. Discovery runs at boot and fails fast if the issuer is unreachable, +so a misconfigured provider is a startup crash rather than a 500 on the first login. + +`OidcConfig.fromEnv()` reads the same settings from `OIDC_*` environment variables, and +`OidcConfig.keycloak(serverUrl, realm, ...)` builds the issuer URL for you. + +## What it registers + +| | | +|---|---| +| `GET {prefix}/login` | Builds the authorization URL with PKCE + state and redirects | +| `GET {prefix}/callback` | Validates state and nonce, exchanges the code, creates the session | +| `POST {prefix}/logout` | Ends the session and redirects to the provider's end-session endpoint | + +`{prefix}` is `routePrefix` (default `/auth`). Logout is a `POST` on purpose — a `GET` logout is +one `` tag away from being triggered by any page the user visits. + +In the context it provides `AuthMiddleware` (from auth-core), `OidcCredentialSource` and +`JwtValidator`. + +## How a request is resolved + +1. `Authorization: Bearer …` — validated against the issuer's JWKS. +2. `oidc_session` cookie — looked up in the `SessionStore`; if the access token has expired and a + refresh token is present, refreshed transparently and the session replaced. +3. Neither, and the client sent `Accept: application/json` → `401` with a + `WWW-Authenticate: Bearer` challenge. +4. Neither, and it looks like a browser → redirect to `{prefix}/login?redirect={path}`. + +Points 3 and 4 are why the source distinguishes "no credential" from "bad credential": an API +client must not be redirected into an HTML sign-in page, and a browser must not be left staring at +a bare 401. + +## Configuration + +| Setting | Default | Notes | +|---|---|---| +| `issuer`, `clientId`, `clientSecret`, `redirectUri` | — | required | +| `scopes` | `openid profile email` | | +| `routePrefix` | `/auth` | | +| `selfScheme` | `http` | `https()` behind TLS; only used when no `X-Forwarded-Proto` | +| `rolesClaimPath` | `realm_access.roles` | Keycloak's spelling; `groups` for Authelia | +| `scopeClaimPaths` | `scope,scp` | comma-separated, tried in order | +| `algorithm` | `RS256` | | +| `postLogoutRedirectUri` | `/` | | +| `sessionStore` | `InMemorySessionStore` | swap for Redis/JDBC across instances | +| `clientAuthMethod` | `POST` | token endpoint client authentication | +| `insecureTls()` | off | dev only, skips certificate validation | +| `schemeName` | derived from the issuer | OpenAPI security scheme name | + +A relative `redirectUri` (starting with `/`) is resolved per request against the incoming `Host`, +or `X-Forwarded-Host`/`-Proto` when behind a proxy — so one build works in dev and behind TLS +without a second configuration. + +## Sessions + +A session holds the claims plus the access, id and refresh tokens, the last three in +`Session.attributes()` under this extension's own keys. Core never reads them; renewal happens +here. See [`../../flash-ext-auth-core/docs/sessions.md`](../../flash-ext-auth-core/docs/sessions.md). + +## Multiple providers + +Two issuers on one server, each with its own route prefix: + +```java +app.install(new OidcExtension(tenantAConfig)) // routePrefix("/tenantA/auth") + .install(new OidcExtension(tenantBConfig)); // routePrefix("/tenantB/auth") +``` + +Both are known at boot. Registering an issuer at runtime — a customer connecting their own IdP from +a settings page — is not supported. + +## Interop + +See [`interop.md`](interop.md) for how this extension fits with `flash-ext-auth-core`, +`flash-ext-openapi` and `flash-ext-mcp`. diff --git a/flash-extensions/flash-ext-auth-oidc/docs/interop.md b/flash-extensions/flash-ext-auth-oidc/docs/interop.md new file mode 100644 index 0000000..d0970ea --- /dev/null +++ b/flash-extensions/flash-ext-auth-oidc/docs/interop.md @@ -0,0 +1,88 @@ +# Interop + +## flash-ext-auth-core + +A hard dependency, and the reason this extension is as small as it is. The division: + +| Here | `flash-ext-auth-core` | +|---|---| +| Discovery, JWKS, PKCE, token endpoint | `@Authenticated`, `@RolesAllowed`, `@ScopesAllowed` | +| `/login`, `/callback`, `/logout` | `ClaimsHolder`, `Claims` | +| Bearer and cookie resolution, refresh | Role and scope matching | +| RFC 6750 `WWW-Authenticate` challenges | `Session`, `SessionStore` | + +`OidcExtension` builds an `OidcCredentialSource`, hands it to `AuthMiddleware.install(...)`, and +that publishes the middleware and registers the annotation processor. Everything a handler +annotation does is core's code running against claims this extension produced. + +Consequence worth knowing: `@RolesAllowed` is not OIDC-specific and never was. An app that swaps +this extension for another credential source keeps every annotation it had. + +## flash-ext-openapi + +Optional, and resolved lazily so this extension runs standalone when openapi is not on the +classpath. When it is, an `OpenApiContributor` is registered that emits an `oauth2` security scheme +with the `authorizationCode` flow, filled in from the discovery document: + +```json +"securitySchemes": { + "myrealm": { + "type": "oauth2", + "flows": { "authorizationCode": { "authorizationUrl": "…", "tokenUrl": "…", "scopes": {…} } } + } +} +``` + +Per-operation security comes from the same annotations the middleware reads, so the spec and the +enforcement cannot drift: both call `AuthPolicy.compileFromAnnotations`. + +The scheme name is `schemeName`, derived from the last path segment of the issuer (a Keycloak realm +name, usually) unless set explicitly. + +## flash-ext-mcp + +`McpSecurity` asks whether **this** extension is installed — `ctx.find(OidcCredentialSource.class)` +— and not merely whether something authenticates: + +| Policy | this extension installed | absent | +|---|---|---| +| `REQUIRED` | protected | **boot fails** | +| `AUTO` | protected | unprotected, warning logged | +| `NONE` | never protected | unprotected | + +That distinction is deliberate. `REQUIRED` means "a real OAuth2 authorization server is protecting +this endpoint", because everything it turns on — RFC 9728 Protected Resource Metadata, RFC 8707 +audience binding, `WWW-Authenticate` challenges carrying `resource_metadata` — is meaningless +without an issuer. An app that authenticates some other way must not satisfy it by accident. + +When it is installed, `McpOidcIntegration` derives the whole resource-server configuration from the +source with no extra `McpConfig` calls: + +- the MCP route is wrapped with `authMw.withSource(source.withResourceMetadata(path)).protect()` — + the same validation every other route uses, plus the `resource_metadata` challenge parameter; +- an audience guard runs after it and rejects any token whose `aud` does not include this + endpoint's resource identifier; +- the resource identifier is resolved per request from `X-Forwarded-Host`/`-Proto`, or the `Host` + header and `selfScheme`. + +An app that does **not** use OAuth2 can still guard `/mcp`: set `McpSecurity.NONE` and pass its own +guard to `McpConfig.middleware(...)`. + +## Migrating from flash-ext-oidc + +The module was renamed and its generic half moved. Mechanically: + +| Was | Now | +|---|---| +| `flash-ext-oidc` (artifact) | `flash-ext-auth-oidc` | +| `dev.relism.flash.ext.oidc.Authenticated` (and `RolesAllowed`, `ScopesAllowed`) | `dev.relism.flash.ext.auth.…` | +| `OidcMiddleware` | `AuthMiddleware` (`dev.relism.flash.ext.auth`) | +| `ctx.find(OidcMiddleware.class)` | `ctx.find(AuthMiddleware.class)` | +| `OidcUser` | `Claims` | +| `ClaimsHolder.user()` | `ClaimsHolder.current()` | +| `ClaimsHolder.get()` | `ClaimsHolder.map()` | +| `OidcSession`, `OidcSessionStore`, `InMemoryOidcSessionStore` | `Session`, `SessionStore`, `InMemorySessionStore` | +| `session.isAccessTokenExpired()` | `session.isExpired()` | +| `session.idToken()` | `session.attributeAsString(OidcCredentialSource.ID_TOKEN)` | + +`OidcConfig`, `OidcExtension` and every setting on them are unchanged. diff --git a/flash-extensions/flash-ext-mcp/docs/security.md b/flash-extensions/flash-ext-mcp/docs/security.md index 412b12e..f6fa0d0 100644 --- a/flash-extensions/flash-ext-mcp/docs/security.md +++ b/flash-extensions/flash-ext-mcp/docs/security.md @@ -16,6 +16,24 @@ installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExten | `AUTO` (default) | protected | runs unprotected, logs a warning | | `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected | +### Guarding `/mcp` without OAuth2 + +`McpSecurity` only ever answers "is `flash-ext-auth-oidc` installed". An app that authenticates +some other way sets `McpSecurity.NONE` and supplies its own guard: + +```java +McpConfig.builder("my-server") + .toolsPackage("com.example.mcp") + .security(McpSecurity.NONE) + .middleware(myAuthMiddleware.protect()) + .build(); +``` + +`McpConfig.middleware(...)` runs after the transport guards and after whatever `McpSecurity` +resolved to, so it composes with OAuth2 protection rather than replacing it — the same hook is how +you add rate limiting, audit logging or tracing to the endpoint. It never satisfies `REQUIRED`, +which still asks for a real authorization server. + Use `REQUIRED` for anything you intend to run in production reachable over the network — it turns "someone forgot to wire up OAuth2" into a startup crash instead of a silently open endpoint. `AUTO` is meant for local development, where spinning up a real identity provider is 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 f088b60..805d53b 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java @@ -1,5 +1,7 @@ package dev.relism.flash.ext.mcp; +import dev.relism.flash.routing.Middleware; + import java.util.ArrayList; import java.util.List; @@ -28,6 +30,7 @@ public final class McpConfig { private final String authorizationServerIssuer; private final List allowedOrigins; private final List scopesSupported; + private final List middleware; private McpConfig(Builder b) { this.name = b.name; @@ -40,6 +43,7 @@ public final class McpConfig { this.authorizationServerIssuer = b.authorizationServerIssuer; this.allowedOrigins = List.copyOf(b.allowedOrigins); this.scopesSupported = List.copyOf(b.scopesSupported); + this.middleware = List.copyOf(b.middleware); } String name() { return name; } @@ -52,6 +56,7 @@ public final class McpConfig { String authorizationServerIssuer() { return authorizationServerIssuer; } List allowedOrigins() { return allowedOrigins; } List scopesSupported() { return scopesSupported; } + List middleware() { return middleware; } public static Builder builder(String name) { return new Builder(name); } @@ -66,6 +71,7 @@ public final class McpConfig { private String authorizationServerIssuer; private final List allowedOrigins = new ArrayList<>(); private final List scopesSupported = new ArrayList<>(); + private final List middleware = new ArrayList<>(); private Builder(String name) { if (name == null || name.isBlank()) @@ -128,6 +134,22 @@ public final class McpConfig { */ public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; } + /** + * Middleware to run on the MCP route, in the order given, after the transport guards and + * after whatever {@link McpSecurity} resolved to. Rate limiting, audit logging, tracing — + * anything that is routine on every other Flash route and had no way in here. + * + *

It runs on an authenticated request when OAuth2 protection is active, and is the only + * thing standing in front of the endpoint when it is not: {@link McpSecurity#NONE} plus a + * middleware of your own is how an app that authenticates some other way guards + * {@code /mcp}. It never satisfies {@link McpSecurity#REQUIRED}, which still asks for a + * real authorization server. + */ + public Builder middleware(Middleware... middleware) { + this.middleware.addAll(List.of(middleware)); + return this; + } + public McpConfig build() { if (toolsPackage == null || toolsPackage.isBlank()) throw new IllegalStateException( diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java index 3126f99..a7a81c8 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 @@ -65,10 +65,11 @@ public class McpExtension implements FlashExtension { secured == null ? null : secured.rolesClaimPath()); McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions()); - List chain = new ArrayList<>(3); + List chain = new ArrayList<>(3 + config.middleware().size()); chain.add(McpTransportGuards.httpExceptionGuard()); chain.add(McpTransportGuards.originGuard(config.allowedOrigins())); if (secured != null) chain.add(secured.security()); + chain.addAll(config.middleware()); app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; }, chain.toArray(Middleware[]::new)); diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java new file mode 100644 index 0000000..cf1f253 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java @@ -0,0 +1,61 @@ +package dev.relism.flash.ext.mcp; + +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Application middleware on the MCP route. Until this existed a consumer had no way to put rate + * limiting, audit logging or tracing in front of {@code /mcp} — the chain was assembled entirely + * inside the extension. + */ +class McpConfigMiddlewareTest { + + private static final AtomicInteger CALLS = new AtomicInteger(); + + @RegisterExtension + static FlashTest mcp = FlashTest.of(app -> app.install(new McpExtension( + McpConfig.builder("middleware-server") + .version("1.0.0") + .toolsPackage("dev.relism.flash.ext.mcp.fixtures") + .security(McpSecurity.NONE) + .middleware( + next -> (req, res) -> { + CALLS.incrementAndGet(); + return next.handle(req, res); + }, + next -> (req, res) -> { + if ("deny".equals(req.header("X-Test-Gate"))) throw HttpException.forbidden(); + return next.handle(req, res); + }) + .build()))); + + @Test + void appMiddlewareRunsOnTheMcpRoute() { + int before = CALLS.get(); + mcp.request() + .json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}") + .post("/mcp") + .expectStatus(200); + assertEquals(before + 1, CALLS.get()); + } + + /** + * The point of the hook: with {@link McpSecurity#NONE}, an app's own guard is the only thing + * in front of the endpoint — which is how an app that does not authenticate with OAuth2 + * protects {@code /mcp} at all. + */ + @Test + void appMiddlewareCanRejectTheRequest() { + mcp.request() + .header("X-Test-Gate", "deny") + .json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}") + .post("/mcp") + .expectStatus(403); + } +}