refactor(ext-oidc): replace auth modules with security extensions #17

Merged
Relism merged 12 commits from feature/ext-auth/split-oidc-into-auth-core into master 2026-09-16 16:00:15 +00:00
11 changed files with 590 additions and 39 deletions
Showing only changes of commit 829b9bf348 - Show all commits
+3 -1
View File
@@ -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)
@@ -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.
+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()`.
@@ -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 `<img>` 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`.
@@ -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.
@@ -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
@@ -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<String> allowedOrigins;
private final List<String> scopesSupported;
private final List<Middleware> 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<String> allowedOrigins() { return allowedOrigins; }
List<String> scopesSupported() { return scopesSupported; }
List<Middleware> 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<String> allowedOrigins = new ArrayList<>();
private final List<String> scopesSupported = new ArrayList<>();
private final List<Middleware> 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.
*
* <p>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(
@@ -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<Middleware> chain = new ArrayList<>(3);
List<Middleware> 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));
@@ -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);
}
}