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
@@ -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);
```
@@ -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<String, Object> authenticate(Request req, Response res);
Map<String, Object> 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<String, Map<String, Object>> keys; // key -> claims
@Override
public Map<String, Object> 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<String, Object> claims = keys.get(key);
if (claims == null) throw HttpException.unauthorized(); // presented and wrong
return claims;
}
@Override
public Map<String, Object> 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.
@@ -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<String, Object> claims();
Instant expiresAt();
Map<String, Object> 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<Session> 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.