diff --git a/README.md b/README.md index 89fc701..6fa1051 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/flash-extensions/flash-ext-auth-core/docs/README.md b/flash-extensions/flash-ext-auth-core/docs/README.md new file mode 100644 index 0000000..be13f11 --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/docs/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); +``` diff --git a/flash-extensions/flash-ext-auth-core/docs/credential-sources.md b/flash-extensions/flash-ext-auth-core/docs/credential-sources.md new file mode 100644 index 0000000..efabb8c --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/docs/credential-sources.md @@ -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 authenticate(Request req, Response res); + Map 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> keys; // key -> claims + + @Override + public Map 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 claims = keys.get(key); + if (claims == null) throw HttpException.unauthorized(); // presented and wrong + return claims; + } + + @Override + public Map 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. diff --git a/flash-extensions/flash-ext-auth-core/docs/sessions.md b/flash-extensions/flash-ext-auth-core/docs/sessions.md new file mode 100644 index 0000000..e17fb67 --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/docs/sessions.md @@ -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 claims(); + Instant expiresAt(); + Map 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 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. diff --git a/flash-extensions/flash-ext-auth-oidc/README.md b/flash-extensions/flash-ext-auth-oidc/README.md index 5fccedf..960d20e 100644 --- a/flash-extensions/flash-ext-auth-oidc/README.md +++ b/flash-extensions/flash-ext-auth-oidc/README.md @@ -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 dev.relism flash-ext-auth-oidc - 1.0-SNAPSHOT + 2.1.0-SNAPSHOT ``` -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 all = u.claims(); ### Raw access (escape hatch) ```java -Map claims = ClaimsHolder.get(); +Map 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 find(String sessionId); +public interface SessionStore { + void save(Session session); + Optional 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 ``` 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()`. diff --git a/flash-extensions/flash-ext-auth-oidc/docs/README.md b/flash-extensions/flash-ext-auth-oidc/docs/README.md new file mode 100644 index 0000000..5a64011 --- /dev/null +++ b/flash-extensions/flash-ext-auth-oidc/docs/README.md @@ -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 `` 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`. diff --git a/flash-extensions/flash-ext-auth-oidc/docs/interop.md b/flash-extensions/flash-ext-auth-oidc/docs/interop.md new file mode 100644 index 0000000..d0970ea --- /dev/null +++ b/flash-extensions/flash-ext-auth-oidc/docs/interop.md @@ -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. diff --git a/flash-extensions/flash-ext-mcp/docs/security.md b/flash-extensions/flash-ext-mcp/docs/security.md index 412b12e..f6fa0d0 100644 --- a/flash-extensions/flash-ext-mcp/docs/security.md +++ b/flash-extensions/flash-ext-mcp/docs/security.md @@ -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 diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java index f088b60..805d53b 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java @@ -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 allowedOrigins; private final List scopesSupported; + private final List 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 allowedOrigins() { return allowedOrigins; } List scopesSupported() { return scopesSupported; } + List 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 allowedOrigins = new ArrayList<>(); private final List scopesSupported = new ArrayList<>(); + private final List 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. + * + *

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( diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java index 3126f99..a7a81c8 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java @@ -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 chain = new ArrayList<>(3); + List 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)); diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java new file mode 100644 index 0000000..cf1f253 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java @@ -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); + } +}