refactor(ext-oidc): replace auth modules with security extensions #17
@@ -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.
|
||||
@@ -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(
|
||||
|
||||
+2
-1
@@ -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));
|
||||
|
||||
+61
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user