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:
@@ -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.
|
||||
Reference in New Issue
Block a user