feat(ext-mcp): let applications put middleware on the MCP route, and document the auth split

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.
This commit is contained in:
Zakaria El Orche
2026-09-10 19:12:44 +00:00
parent 9d39e24ccb
commit 829b9bf348
11 changed files with 590 additions and 39 deletions
+49 -37
View File
@@ -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
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-auth-oidc</artifactId>
<version>1.0-SNAPSHOT</version>
<version>2.1.0-SNAPSHOT</version>
</dependency>
```
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<String, Object> all = u.claims();
### Raw access (escape hatch)
```java
Map<String, Object> claims = ClaimsHolder.get();
Map<String, Object> 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<OidcSession> find(String sessionId);
public interface SessionStore {
void save(Session session);
Optional<Session> 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 <access_token>
```
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()`.