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
129 changed files with 3249 additions and 4223 deletions
+2 -2
View File
@@ -17,8 +17,8 @@
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-security-oidc/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-security-oidc/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
+11 -3
View File
@@ -11,8 +11,12 @@ 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-oidc` | OIDC Authorization Code + PKCE flow |
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-oidc |
| `flash-extensions/flash-ext-security-core` | Security: authentication chain, annotations, sessions, OpenAPI |
| `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
| `flash-extensions/flash-ext-security-apikey` | API keys |
| `flash-extensions/flash-ext-security-form` | Password sign-in |
| `flash-extensions/flash-ext-security-test` | Test identities, fake OpenID Provider |
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, secured by flash-ext-security-core |
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
@@ -146,7 +150,11 @@ 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-oidc`](flash-extensions/flash-ext-oidc/README.md)
- [`flash-ext-security-core`](flash-extensions/flash-ext-security-core/docs/README.md)
- [`flash-ext-security-oidc`](flash-extensions/flash-ext-security-oidc/docs/README.md)
- [`flash-ext-security-apikey`](flash-extensions/flash-ext-security-apikey/docs/README.md)
- [`flash-ext-security-form`](flash-extensions/flash-ext-security-form/docs/README.md)
- [`flash-ext-security-test`](flash-extensions/flash-ext-security-test/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)
@@ -36,7 +36,7 @@ FlashApp.create(8080)
// With custom resolvers
LimiterConfig conf = new LimiterConfig()
.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous");
FlashApp.create(8080)
.install(new LimiterExtension(conf))
@@ -35,11 +35,11 @@ conf.registerResolver("ip", req -> {
LimiterConfig conf = new LimiterConfig();
```
### By authenticated user (OIDC / ClaimsHolder)
### By authenticated user
```java
conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous");
```
Requests from unauthenticated users share the `"anonymous"` bucket. If you want
@@ -88,7 +88,7 @@ returns the same key for the same user regardless of endpoint; the limit is set
```java
conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anon");
SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anon");
```
```java
@@ -11,7 +11,7 @@ import dev.relism.flash.models.Request;
*
* <pre>{@code
* conf.registerResolver("ip", req -> req.header("X-Forwarded-For"));
* conf.registerResolver("auth_user", req -> ClaimsHolder.user().sub());
* conf.registerResolver("auth_user", req -> SecurityIdentity.current().principal().name());
* }</pre>
*/
@FunctionalInterface
@@ -21,8 +21,8 @@ import java.util.Map;
* <pre>{@code
* LimiterConfig conf = new LimiterConfig()
* .registerResolver("auth_user", req -> {
* // custom logic — e.g. extract sub from ClaimsHolder
* return ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous";
* // custom logic — e.g. key by the authenticated caller
* return SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous";
* });
*
* app.install(new LimiterExtension(conf));
@@ -43,7 +43,7 @@ import java.util.Map;
* <h3>Lambda routes (via Guard)</h3>
* <pre>{@code
* app.install(new LimiterExtension(
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
* new LimiterConfig().registerResolver("auth_user", req -> SecurityIdentity.current().principal().name())));
*
* // inside a FlashContext.onReady(...) callback:
* Guard guard = ctx.require(Guard.class);
@@ -3,7 +3,7 @@
`flash-ext-mcp` turns a Flash5 app into an [MCP](https://modelcontextprotocol.io) (Model Context
Protocol) server: JSON-RPC 2.0 over the Streamable HTTP transport, tools/resources/prompts
declared as plain classes and discovered at boot, optional OAuth2 protection built on
`flash-ext-oidc`.
`flash-ext-security-core`.
## Quick Start
@@ -44,7 +44,7 @@ public class GetWeatherTool extends McpTool {
`tools-resources-prompts.md`.
- **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
for exactly what that means and why.
- **Security**: optional, policy-driven OAuth2 via `flash-ext-oidc` — see `security.md`.
- **Security**: authenticated by `flash-ext-security-core`, an OAuth2 protected resource when OIDC is installed — see `security.md`.
- **JSON**: this extension owns its JSON handling independently of `flash-ext-jackson` — see
`jackson-interop.md` for why, and how a future opt-in reuse could work.
@@ -53,6 +53,4 @@ public class GetWeatherTool extends McpTool {
- [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts
- [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation
- [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707
- [`keycloak.md`](keycloak.md) — Keycloak-specific setup cookbook: Dynamic Client Registration,
the RFC 8707 audience mapper gotcha, and how to verify/debug it
- [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson`
@@ -24,7 +24,7 @@ see `tools-resources-prompts.md` — and the fixed `TextContent`/`TextResourceCo
as a `JsonNode` tree, not as a databound class, for the same reason — a JSON-RPC tool call's
arguments aren't a DTO with getters/setters, they're a dynamic, per-tool-defined bag of values.
This mirrors how `flash-ext-oidc` already handles its own internal JSON needs (`json-smart` for
This mirrors how `flash-ext-security-oidc` handles its own JSON needs (Nimbus's parser for
token-endpoint responses) independently of `flash-ext-jackson` — extensions with protocol-level
JSON needs that are shaped by a spec, not by user code, own that JSON handling themselves rather
than routing it through the app's general-purpose JSON extension.
@@ -44,8 +44,7 @@ Nothing here rules out a later, additive convenience layer: `McpExtension.routes
that shared mapper as the backing for an escape hatch such as `ToolArguments.as(Class<T>)` or
for a tool that wants to `ToolResponse.success(someRecord)` and have it serialized with the
app's own conventions — falling back to a locally-constructed default `ObjectMapper` when
`flash-ext-jackson` isn't installed, the same "prefer shared, degrade to sane default" shape
already used for `McpSecurity.AUTO`. That would be purely additive on top of the
`flash-ext-jackson` isn't installed. That would be purely additive on top of the
`JsonGenerator`-based envelope/content writing described above, not a replacement for it — the
fixed-shape protocol plumbing has no reason to ever go through databinding, regardless of what
convenience layer gets added around it.
@@ -1,107 +0,0 @@
# Keycloak cookbook
`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any
`flash-ext-oidc`-compatible provider. This is the Keycloak-specific setup: the exact Admin
Console configuration for a working MCP OAuth2 flow with open Dynamic Client Registration
(DCR) — no pre-registered clients, any MCP client self-registers on first connect.
## 1. Allow Dynamic Client Registration
MCP clients (Claude Desktop, Claude.ai, MCP Inspector, others) don't share one static OAuth
client — each has its own `redirect_uri` and none know your realm in advance. They self-register
on first connect via `POST {issuer}/clients-registrations/openid-connect` (the
`registration_endpoint` from the AS metadata document, reached via the RFC 9728 Protected
Resource Metadata document `McpExtension` publishes).
**Clients → Client registration**: remove the **Trusted Hosts** policy — it rejects anonymous
registration from hosts not on an explicit allowlist (`403` / `"Host not trusted"`), which
doesn't scale to arbitrary future agents. This does not weaken end-user authentication — DCR
only grants an app a `client_id`; every user still authenticates against Keycloak's real login
screen regardless of which client asked. Lighter hygiene policies (**Max Clients Limit**,
**Consent Required**) can stay, they don't interfere.
## 2. RFC 8707 audience: mapper on `basic`, not a custom scope
`McpOidcIntegration` rejects (403) any token whose `aud` doesn't include the MCP endpoint's
canonical URL. Keycloak doesn't add this by default. The obvious fix — a custom client scope
with an Audience mapper, marked Default, added to Allowed Client Scopes — **does not work**:
clients created via the `openid-connect` DCR endpoint only ever get scopes they explicitly
request, and most MCP clients (including MCP Inspector) don't request anything beyond what a
server tells them to via `scopes_supported` (step 3). Default-scope auto-attachment, which is
how a normal manually-created client would pick up a custom Default scope, doesn't apply to
DCR-created clients at all.
`basic` is the one built-in scope Keycloak attaches to every client unconditionally, regardless
of what it registered with. Put the audience mapper there:
1. **Client scopes → `basic`****Mappers****Add mapper****By configuration**
**Audience**.
2. **Included Custom Audience** = the exact value your server expects — check
`GET {parent-of-rootPath}/.well-known/oauth-protected-resource{rootPath}` on the running
server for the `resource` field it publishes (auto-derived from the request's
forwarded/`Host` headers — see `security.md`). Leave **Included Client Audience** empty (that
targets another Keycloak client, not a resource URL).
3. **Add to access token** = ON.
4. **Save.**
This is unconditional and works regardless of client cooperation — keep it even after step 3
below gets other claims flowing normally, since audience binding is a hard spec requirement
that shouldn't depend on a client bothering to request the right scope.
## 3. Other claims (username, email...): `scopes_supported` + Allowed Client Scopes
`OidcUser.username()`/`.email()`/`.name()` read `preferred_username`/`email`/`name` — normally
from the `profile`/`email` client scopes, which DCR clients don't get either, same root cause.
Unlike audience, this **is** fixable the "normal" way, because it doesn't need to survive a
completely uncooperative client:
`McpConfig.scopesSupported("openid", "profile", "email")` publishes those scopes in the PRM
document. MCP clients that read it (confirmed for MCP Inspector) echo them back in their DCR
registration request — `"scope": "openid profile email offline_access"` (`offline_access` is
Inspector's own addition, for refresh tokens). For that request to actually succeed, **Allowed
Client Scopes** needs, exactly:
- **`openid` listed explicitly.** The one genuinely non-obvious step: `openid` is not covered by
**Allow Default Scopes** (On by default) the way other realm-Default scopes are, even though
every OIDC request includes it. Until it's listed here, registration fails with a generic
`403 insufficient_scope` / `"Not permitted to use specified clientScope"` regardless of
whether everything else is configured correctly.
- **`offline_access` listed explicitly** — it's Optional, not Default, so `ALLOW_DEFAULT_SCOPES`
doesn't cover it either.
- **`profile`/`email` — do not list them here.** Mark them **Default** on the **Client scopes**
page (Assigned Type column) instead, and leave **Allow Default Scopes** = On. Adding an
already-Default scope to this list explicitly gets rejected on save
(`"Client scopes not allowed: [...]"`) — the list is for *additional* Optional scopes only.
With that, a real client's token comes back with `preferred_username`/`email` populated
normally.
### Fallback for anything else
For a claim not covered by `openid profile email` (a custom attribute, a role) — or for a client
that ignores `scopes_supported` entirely — add a **User Property** mapper to `basic` too
(Property `username` → Token Claim Name `preferred_username`, or whatever's needed), same as the
audience mapper in step 2. Unconditional, works regardless of client cooperation, costs one
mapper per claim, once, at the realm level — not per tool.
## Verifying without a full OAuth round-trip
**Clients → (any client) → Client scopes → Evaluate**: pick a user, run it — Default scopes
(including `basic`) apply automatically and won't appear in the "Select scope parameters"
picker, which only lists Optional ones — and check the **Generated Access Token** preview.
Confirms mappers work without a browser + real MCP client round-trip each time.
## If a real client still gets rejected
`McpOidcIntegration.audienceGuard` logs the actual mismatch at `WARN`:
```
[flash-ext-mcp] Rejecting token (RFC 8707): aud=<token's actual aud> does not include expected
resource identifier "<what this server expects>" — ...
```
`aud=null` → the `basic` mapper produced nothing (most common cause: **Included Custom
Audience** left blank — the mapper saves fine and silently does nothing without it). A non-null
`aud` that still doesn't match → compare byte-for-byte — the expected side is derived from the
request's own forwarded/`Host` headers, so scheme/host/trailing-slash mismatches show up here
directly, as does a proxy hop that drops `X-Forwarded-Host`.
+32 -155
View File
@@ -1,170 +1,47 @@
# Security
Provider-specific setup steps (not generic OAuth2 mechanics) live in separate cookbooks —
[`keycloak.md`](keycloak.md) for Keycloak: enabling Dynamic Client Registration, why the RFC 8707
audience mapper needs to go on the built-in `basic` scope instead of a custom one, and the exact
Allowed Client Scopes configuration `scopes_supported` needs to actually work.
The MCP endpoint is secured by [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md):
whatever mechanisms the application registers — OAuth2 bearer tokens, API keys, custom ones —
authenticate `/mcp` exactly as they authenticate every other route.
## `McpSecurity`
| `McpConfig.security(...)` | |
|---|---|
| `REQUIRED` (default) | every call must be authenticated; boot fails without a `SecurityExtension` |
| `NONE` | a public endpoint; a tool carrying security annotations fails the boot |
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-oidc` being
installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExtension.routes()`:
## OAuth2 protected resource
| Policy | `flash-ext-oidc` installed | `flash-ext-oidc` absent |
|---|---|---|
| `REQUIRED` | protected | **boot fails** (`IllegalStateException`) |
| `AUTO` (default) | protected | runs unprotected, logs a warning |
| `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected |
When a registered mechanism publishes an OAuth2 issuer — `flash-ext-security-oidc` does — the endpoint
behaves as the MCP authorization spec requires, with nothing to configure:
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
friction you don't want yet.
- `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata: the `resource` (derived per
request from `X-Forwarded-Proto`/`-Host` or `Host`), every issuer as `authorization_servers`, and
`scopes_supported` when `McpConfig.scopesSupported(...)` is set;
- an anonymous call gets `401` with `WWW-Authenticate: Bearer resource_metadata="…"`;
- a token whose `aud` does not include the resource is `403` (RFC 8707) and logged at `WARN`. Credentials
that are not audience-bound, such as API keys, are unaffected.
## Why `flash-ext-oidc` is an *optional* Maven dependency, concretely
For Keycloak, the audience comes from an *Audience* protocol mapper whose included custom audience is
the resource URL, attached to a client scope every MCP client receives (the built-in `basic` scope is the
one that needs no client cooperation). Clients that register dynamically need Keycloak's anonymous
client registration policies relaxed for the trusted hosts.
Maven's `<optional>true</optional>` only affects **transitive** propagation: consumers of
`flash-ext-mcp` don't get `flash-ext-oidc` pulled in automatically unless they add it themselves.
Within `flash-ext-mcp` itself, `flash-ext-oidc`'s classes are on the compile/test classpath as
normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in
source.
`McpConfig.requireTokenAudience(false)` drops that last check for an authorization server that cannot
mint a resource audience at all — Keycloak ignores RFC 8707's `resource` parameter, so a deployment that
cannot add the mapper has no other way in. Every token a registered issuer signs is then accepted on the
endpoint, and the boot logs say so.
That reference is isolated in its own class, `McpOidcIntegration`, invoked only from inside a
`catch (NoClassDefFoundError)` block. A bare class-literal like `OidcMiddleware.class` (which
`ctx.find(OidcMiddleware.class)` needs) forces the JVM to resolve that type the moment it's
evaluated — if `flash-ext-oidc` is not on the *runtime* classpath at all (a genuinely
MCP-only install, no OAuth2 anywhere in the app), the first such reference throws
`NoClassDefFoundError`. Keeping that reference inside a separate, lazily-loaded class means
`McpExtension` itself loads and works fine standalone; only the attempt to actually use OIDC
fails, and only when there's something to fail. This mirrors `OidcExtension`'s own lazy bridge to
`flash-ext-openapi` — same technique, same reason.
## Tool policies
## OAuth2 resolution details — zero-config by default
When oidc is available and `security() != NONE`, `McpOidcIntegration` (an isolated,
lazily-loaded bridge — see its javadoc) derives everything an MCP OAuth2 resource server needs
straight from the installed `OidcMiddleware`, with no additional `McpConfig` calls required:
1. The MCP route is wrapped with `flash-ext-oidc`'s own `OidcMiddleware.protect(resourceMetadataPath)`
— the same Bearer-token/JWKS validation path used everywhere else in Flash5, plus a
`resource_metadata` challenge parameter (see below). No JWT parsing or JWKS handling is
reimplemented here.
2. An audience guard always runs after `protect(...)`: it reads the validated claims from
`ClaimsHolder` and rejects (`403`) any token whose `aud` claim does not include the resource
identifier — **RFC 8707 Resource Indicators / audience binding**, enforced unconditionally,
not opt-in. `OidcMiddleware` itself validates `aud` against its own `clientId` for ID
tokens, but deliberately does not enforce audience on access tokens (it varies by provider)
— the MCP extension adds that check on top, scoped to its own resource identifier.
3. The resource identifier is the canonical URI of the MCP endpoint, resolved **per request** by
`OidcMiddleware#selfOrigin` + `rootPath` — the same scheme/host resolution `OidcExtension`
uses for its own redirect URIs: `X-Forwarded-Host`/`X-Forwarded-Proto` when the request came
through a reverse proxy, otherwise `{selfScheme()}://{Host header}`. Behind a proxy the
`Host` alone is the upstream address the proxy dialled, which would publish a resource
identifier no client can reach. `McpConfig.resourceIdentifier(...)` still overrides it
outright for a proxy that forwards neither header.
4. The authorization server issuer is read from `OidcMiddleware#issuer()` unless
`McpConfig.authorizationServerIssuer(...)` overrides it.
## RFC 9728 Protected Resource Metadata
Whenever the endpoint ends up protected, `flash-ext-mcp` publishes a Protected Resource Metadata
document at `/.well-known/oauth-protected-resource{rootPath}` — no explicit `resourceIdentifier`/
`authorizationServerIssuer` configuration required, both are auto-derived as described above:
```json
{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/realms/myrealm"] }
```
`resource` is computed per request from the incoming request's forwarded/`Host` headers (see
above), so the document is correct without hardcoding the server's own public URL.
### `scopes_supported`
Optional per RFC 9728, omitted from the document entirely unless set via
`McpConfig.scopesSupported("openid", "profile", "email")`:
```json
{ "resource": "...", "authorization_servers": ["..."], "scopes_supported": ["openid", "profile", "email"] }
```
This is pure advertisement — token validation doesn't change based on it — but it matters in
practice: a client that ignores it and requests no scope at all (many do — see `keycloak.md`)
only gets back whatever the authorization server treats as always-included regardless of
request, which for Keycloak is just its built-in `basic` scope. A client that *does* read
`scopes_supported` and echoes it back in its authorization/token requests gets a token with the
claims those scopes actually provide (`profile``preferred_username`/`name`, etc.), without
needing every one of those claims hand-mapped onto `basic`. Set it to whatever scopes your
`McpTool`s actually read off `ClaimsHolder`/`OidcUser` — there's no way to auto-derive this list,
it depends entirely on what your tools do with the claims.
## `WWW-Authenticate: resource_metadata` (RFC 9728 §5.1)
The MCP Authorization spec **requires** a `401` to carry `resource_metadata` in
`WWW-Authenticate`, pointing at the Protected Resource Metadata document above — this is how a
spec-compliant client discovers the authorization server without out-of-band configuration.
`OidcMiddleware.protect(String resourceMetadataPath)` (an overload added specifically for this)
builds that challenge automatically:
```
WWW-Authenticate: Bearer realm="...", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
```
The plain `OidcMiddleware.protect()` (no argument), used by every other Flash5 app, is
unaffected — this parameter is additive and MCP-specific.
## Per-tool `@RolesAllowed`/`@ScopesAllowed`
`McpTool` subclasses can carry `flash-ext-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
The core annotations work on tools as on handlers, checked per `tools/call` against the caller the route
authenticated:
```java
@Tool(name = "delete_route", description = "Delete a route")
@RolesAllowed("admin")
public class DeleteRouteTool extends McpTool {
@Override public ToolResponse call(ToolArguments args) { ... }
}
@Tool(name = "approve", description = "Approves a pending proposal")
@RolesAllowed(value = "REVIEWER", on = {"project", "locale"}) // read from the tool's arguments
public class ApproveTool extends McpTool { }
```
This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
the thing that makes these annotations work on a `RequestHandler`) — it can't: every tool shares
one HTTP route (`POST {rootPath}`), already wrapped by whatever `McpSecurity` resolved above, so
there is no per-tool route to attach a different middleware chain to. Instead,
`McpOidcIntegration.compileToolPolicy` reads the annotations once at boot (`McpRegistry.scan`)
and compiles them into a closure (`McpAuthPolicy`) that `McpDispatcher` runs *after* the
route-wide auth has already succeeded and *before* invoking the specific tool named in the
`tools/call` request — narrowing what's already-authenticated, not replacing it. A denial is a
normal `isError: true` tool result (see `ToolResponse.error`), not an HTTP-level rejection — the
model sees why, the same as any other tool failure.
A denial is a tool result with `isError: true` — the call reached the server, the tool did not run.
Roles are read via `OidcUser#hasRole` against `McpConfig.rolesClaimPath(...)` (default
`"realm_access.roles"`, matching `OidcConfig`'s own default — set this explicitly if the two
diverge; there's no way to read `OidcConfig`'s actual configured value from here). Scopes use
`OidcUser#hasScope`'s built-in default claim paths (`scope`/`scp`), no extra config needed.
`@ScopesAllowed(match = ScopesAllowed.Match.ANY)` and multi-role `@RolesAllowed({"admin",
"editor"})` (OR semantics) both work exactly as they do on a `RequestHandler`.
**`@Authenticated` alone has no effect and fails boot.** Once oidc is active for a server, every
tool call is already authenticated — there's no per-tool public/authenticated split the way
there is for HTTP routes, so a bare `@Authenticated` on a tool can't mean anything and would
silently do nothing if allowed to compile. Boot fails instead, with a message pointing at
`@RolesAllowed`/`@ScopesAllowed` as the actual narrowing mechanism.
**Annotating a tool without active OAuth2 also fails boot**, not silently at request time: if
`@RolesAllowed`/`@ScopesAllowed`/`@Authenticated` shows up on a tool while `McpSecurity` resolved
to unprotected (`NONE`, or `AUTO` with no oidc installed), that's very likely a forgotten
`OidcExtension` install or a `McpSecurity.NONE` left over from local dev — `IllegalStateException`
at `app.start()`.
## The `HttpException` safety net
`flash-ext-oidc`'s middleware throws `HttpException.unauthorized()`/`forbidden()` on auth
failure. Flash5's core does **not** special-case `HttpException` in the default exception
handler — the out-of-the-box `AbstractRouter` default always returns a generic `500`, regardless
of the thrown exception's embedded status code; only an app that explicitly calls
`FlashApp#onException(...)` (or installs something that does) gets `HttpException.status()`
honored.
To keep the MCP endpoint correct regardless of what the rest of the app configures,
`McpTransportGuards.httpExceptionGuard()` wraps the whole route and translates `HttpException`
into the right HTTP status itself, rather than letting it fall through to the app's (possibly
unconfigured) global handler. This is scoped entirely to the MCP route — it does not touch or
override the app's `onException` for any other route.
`McpConfig.middleware(...)` runs after authentication, for rate limiting, auditing or tracing.
+17 -2
View File
@@ -19,8 +19,7 @@
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId>
<optional>true</optional>
<artifactId>flash-ext-security-core</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
@@ -43,6 +42,22 @@
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-test</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-oidc</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-apikey</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -1,23 +0,0 @@
package dev.relism.flash.ext.mcp;
import java.util.function.Supplier;
/**
* Compiled per-tool authorization requirement, built once at boot by {@link McpOidcIntegration}
* from {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} subclass — {@code null}
* on {@link McpRegistry.RegisteredTool} means no restriction beyond whatever {@link McpSecurity}
* already enforces route-wide.
*
* <p>{@code check} is a closure, not a raw role/scope list — this is what lets this record (and
* its only caller, {@link McpDispatcher}) stay free of any compile-time reference to a {@code
* flash-ext-oidc} type, preserving the same classload isolation {@link McpOidcIntegration}'s
* javadoc describes for the rest of the OIDC bridge. Only the plain-JDK {@link Supplier}
* signature crosses the boundary; the closure itself, built once inside {@code
* McpOidcIntegration}, is the only place that ever touches {@code OidcUser}/{@code ClaimsHolder}.
*
* <p>Returns {@code null} from {@link #check()}{@code .get()} when authorized, or a
* human-readable denial reason otherwise — invoked once per {@code tools/call} against an
* annotated tool, never allocated on that path (the closure and its captured role/scope arrays
* are built exactly once, at boot).
*/
record McpAuthPolicy(Supplier<String> check) {}
@@ -1,5 +1,7 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.routing.Middleware;
import java.util.ArrayList;
import java.util.List;
@@ -24,10 +26,10 @@ public final class McpConfig {
private final String rootPath;
private final String toolsPackage;
private final McpSecurity security;
private final String resourceIdentifier;
private final String authorizationServerIssuer;
private final boolean requireTokenAudience;
private final List<String> allowedOrigins;
private final List<String> scopesSupported;
private final List<Middleware> middleware;
private McpConfig(Builder b) {
this.name = b.name;
@@ -36,10 +38,10 @@ public final class McpConfig {
this.rootPath = b.rootPath;
this.toolsPackage = b.toolsPackage;
this.security = b.security;
this.resourceIdentifier = b.resourceIdentifier;
this.authorizationServerIssuer = b.authorizationServerIssuer;
this.requireTokenAudience = b.requireTokenAudience;
this.allowedOrigins = List.copyOf(b.allowedOrigins);
this.scopesSupported = List.copyOf(b.scopesSupported);
this.middleware = List.copyOf(b.middleware);
}
String name() { return name; }
@@ -48,10 +50,10 @@ public final class McpConfig {
String rootPath() { return rootPath; }
String toolsPackage() { return toolsPackage; }
McpSecurity security() { return security; }
String resourceIdentifier() { return resourceIdentifier; }
String authorizationServerIssuer() { return authorizationServerIssuer; }
boolean requireTokenAudience() { return requireTokenAudience; }
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); }
@@ -61,11 +63,11 @@ public final class McpConfig {
private String instructions;
private String rootPath = "/mcp";
private String toolsPackage;
private McpSecurity security = McpSecurity.AUTO;
private String resourceIdentifier;
private String authorizationServerIssuer;
private McpSecurity security = McpSecurity.REQUIRED;
private boolean requireTokenAudience = true;
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())
@@ -85,28 +87,16 @@ public final class McpConfig {
/** Package scanned for {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes. Required. */
public Builder toolsPackage(String toolsPackage) { this.toolsPackage = toolsPackage; return this; }
/** OAuth2 requirement policy. Default {@link McpSecurity#AUTO}. */
/** Default {@link McpSecurity#REQUIRED}. */
public Builder security(McpSecurity security) { this.security = security; return this; }
/**
* Canonical URI of this MCP endpoint, used for RFC 8707 audience binding: tokens whose
* {@code aud} claim does not include this value are rejected. Optional — when
* {@code flash-ext-oidc} is installed, this is auto-derived per request from the
* forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own
* redirect URIs) and audience binding is enforced unconditionally. Set this explicitly
* only to override that guess — a reverse proxy that forwards neither
* {@code X-Forwarded-Host} nor {@code X-Forwarded-Proto}.
* Whether a bearer token must name this endpoint in its {@code aud} (RFC 8707), as the MCP
* authorization spec requires. Default {@code true}. Turn it off for an authorization server
* that cannot mint a resource audience — every token a registered issuer signs is then
* accepted on the endpoint, and a warning is logged at boot.
*/
public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; }
/**
* Authorization server issuer URL, published in the RFC 9728 Protected Resource
* Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}}. Optional
* — when {@code flash-ext-oidc} is installed, this is auto-derived from its configured
* issuer. Set this explicitly only to override that (e.g. publishing a different issuer
* than the one actually validating tokens).
*/
public Builder authorizationServerIssuer(String issuer) { this.authorizationServerIssuer = issuer; return this; }
public Builder requireTokenAudience(boolean require) { this.requireTokenAudience = require; return this; }
/**
* Origins allowed to call the MCP endpoint (DNS-rebinding protection, per the Streamable
@@ -115,19 +105,15 @@ public final class McpConfig {
*/
public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; }
/**
* OAuth2 scopes this server expects clients to request, published as {@code
* scopes_supported} in the RFC 9728 Protected Resource Metadata document. Optional per
* the spec — omitted from the document entirely if never set. A spec-compliant client
* reads this to know what to put in its authorization/token requests instead of
* requesting nothing; see {@code docs/keycloak.md}'s "same story for any other claim"
* section for why this matters in practice (a client that requests no scope only gets
* whatever your authorization server treats as always-included, e.g. Keycloak's `basic`).
* Purely advertisement — this server still validates whatever token it actually receives
* the same way regardless of what a client requested.
*/
/** Published as {@code scopes_supported} in the RFC 9728 metadata, so OAuth clients request them. */
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
/** Runs on the MCP route after the transport guards and authentication — rate limiting, auditing, tracing. */
public Builder middleware(Middleware... middleware) {
this.middleware.addAll(List.of(middleware));
return this;
}
public McpConfig build() {
if (toolsPackage == null || toolsPackage.isBlank())
throw new IllegalStateException(
@@ -3,6 +3,8 @@ package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonNode;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.ext.security.SecurityPolicy;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
@@ -18,10 +20,10 @@ import java.io.IOException;
* tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object,
* always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only
* malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. A
* {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link McpAuthPolicy}) is the same
* {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link SecurityPolicy}) is the same
* category — {@code isError: true}, tool never invoked — not a transport-level rejection; the
* route-wide 401/403 for "not authenticated at all" already happened earlier, in the {@code
* OidcMiddleware}/audience-guard middleware chain, before this dispatcher ever runs.
* route-wide 401/403 already happened earlier, in the security middleware, before this
* dispatcher ever runs.
*/
final class McpDispatcher {
@@ -136,12 +138,14 @@ final class McpDispatcher {
if (tool == null)
throw McpProtocolException.invalidParams("Unknown tool: " + name);
ToolResponse result;
String denied = tool.policy() != null ? tool.policy().check().get() : null;
if (denied != null) {
result = ToolResponse.error("Tool \"" + name + "\" denied: " + denied);
} else {
ToolArguments args = new ToolArguments(params.path("arguments"));
SecurityPolicy policy = tool.policy();
ToolResponse result;
if (policy != null && !policy.permitsScopes(SecurityIdentity.current())) {
result = ToolResponse.error("Tool \"" + name + "\" denied: missing scope");
} else if (policy != null && !policy.permitsRoles(SecurityIdentity.current(), args::getString)) {
result = ToolResponse.error("Tool \"" + name + "\" denied: missing role");
} else {
try {
result = tool.instance().call(args);
} catch (Exception e) {
@@ -1,5 +1,10 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.ext.security.SecurityPolicy;
import dev.relism.flash.ext.security.SecurityScheme;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
@@ -9,38 +14,25 @@ import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single
* {@code POST} JSON-RPC endpoint, stateless in this revision (no session, no SSE stream; see
* {@code docs/transport.md}) — dispatch precompiled at boot from classes annotated with
* {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} under
* MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single {@code POST}
* JSON-RPC endpoint, stateless in this revision (see {@code docs/transport.md}) — dispatching to
* {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes under
* {@link McpConfig#toolsPackage(String)}.
*
* <pre>{@code
* // Standalone, no OAuth2
* FlashApp.create(8080)
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
* .toolsPackage("com.example.tools")
* .build()))
* .start();
*
* // With flash-ext-oidc as the OAuth2 resource server — zero extra config: issuer, canonical
* // resource identifier, RFC 8707 audience binding and RFC 9728 metadata are all derived from
* // the installed OidcExtension.
* FlashApp.create(8080)
* .install(new OidcExtension(oidcConfig))
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
* .toolsPackage("com.example.tools")
* .security(McpSecurity.REQUIRED)
* .build()))
* .start();
* app.install(new SecurityExtension())
* .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)))
* .install(new McpExtension(McpConfig.builder("my-server").toolsPackage("com.example.tools").build()));
* }</pre>
*
* <p>One server per {@code McpExtension} instance — install multiple instances (distinct
* {@code rootPath}, distinct {@code toolsPackage}) for multiple MCP servers on one app,
* mirroring the {@code OidcExtension} multi-tenant pattern. See {@code docs/security.md} for
* the full OAuth2 resolution rules.
* <p>Every call is authenticated by the application's security chain — OAuth2 bearer tokens, API
* keys, anything registered. When an OAuth2 issuer is among its schemes, the endpoint is also an
* OAuth2 protected resource: RFC 9728 metadata, a {@code resource_metadata} challenge, and RFC 8707
* audience binding for audience-bound tokens. Tool annotations are enforced per call, with
* {@code @RolesAllowed(on = ...)} reading tool arguments.
*/
@Slf4j
public class McpExtension implements FlashExtension {
@@ -53,68 +45,54 @@ public class McpExtension implements FlashExtension {
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.onReady(() -> registerRoutes(app, ctx));
}
ctx.onReady(() -> {
SecurityExtension security = config.security() == McpSecurity.NONE ? null : ctx.find(SecurityExtension.class)
.orElseThrow(() -> new IllegalStateException("MCP server \"" + config.name()
+ "\" requires flash-ext-security-core: install a SecurityExtension, or set McpSecurity.NONE for a public server"));
McpDispatcher dispatcher = new McpDispatcher(McpRegistry.scan(config.toolsPackage(), ctx, security),
config.name(), config.version(), config.instructions());
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
// Resolved before scanning so McpRegistry knows, per tool, whether @RolesAllowed/
// @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration
// (see McpOidcIntegration#compileToolPolicy) — must run first, not after.
McpOidcIntegration.Resolved secured = resolveSecurity(ctx);
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null,
secured == null ? null : secured.rolesClaimPath());
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
List<Middleware> chain = new ArrayList<>(3);
chain.add(McpTransportGuards.httpExceptionGuard());
chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
if (secured != null) chain.add(secured.security());
app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; },
chain.toArray(Middleware[]::new));
registerResourceMetadata(app, secured);
}
private McpOidcIntegration.Resolved resolveSecurity(FlashContext ctx) {
if (config.security() == McpSecurity.NONE) return null;
McpOidcIntegration.Resolved resolved;
try {
resolved = McpOidcIntegration.resolve(ctx, config);
} catch (NoClassDefFoundError e) {
resolved = null; // flash-ext-oidc not on the classpath at all
}
if (resolved != null) return resolved;
if (config.security() == McpSecurity.REQUIRED) {
throw new IllegalStateException(
"McpSecurity.REQUIRED but flash-ext-oidc is not installed for MCP server \"" + config.name() +
"\" — install an OidcExtension before this McpExtension, or relax security to " +
"McpSecurity.AUTO/NONE if this server is meant to be public.");
}
log.warn("[flash-ext-mcp] MCP server \"{}\" is running WITHOUT OAuth2 protection — " +
"flash-ext-oidc is not installed and McpSecurity.AUTO degrades to unprotected. " +
"Install flash-ext-oidc or set McpSecurity.REQUIRED to make this a hard failure instead.",
config.name());
List<Middleware> chain = new ArrayList<>(List.of(
McpTransportGuards.httpExceptionGuard(), McpTransportGuards.originGuard(config.allowedOrigins())));
if (security != null) protect(app, security, chain);
chain.addAll(config.middleware());
app.post(config.rootPath(), (req, res) -> {
dispatcher.handle(req, res);
return null;
}
/**
* RFC 9728 Protected Resource Metadata, built once security is resolved — no longer
* conditioned on {@code resourceIdentifier}/{@code authorizationServerIssuer} being set
* explicitly, since {@link McpOidcIntegration#resolve} now derives both by default. The
* {@code resource} field is computed per request (it depends on that request's own
* forwarded/{@code Host} headers) via {@link McpOidcIntegration.Resolved#resourceIdentifier()}.
*/
private void registerResourceMetadata(FlashRegistrar<?> app, McpOidcIntegration.Resolved secured) {
if (secured == null) return;
String path = "/.well-known/oauth-protected-resource" + config.rootPath();
app.get(path, (req, res) -> {
res.type(ContentType.JSON);
return McpResourceMetadata.build(
secured.resourceIdentifier().apply(req), secured.issuer(), config.scopesSupported());
}, chain.toArray(Middleware[]::new));
});
}
private void protect(FlashRegistrar<?> app, SecurityExtension security, List<Middleware> chain) {
String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
chain.add(security.enforce(SecurityPolicy.AUTHENTICATED, (req, res) -> {
List<String> issuers = issuers(security);
res.header("WWW-Authenticate", issuers.isEmpty()
? String.join(", ", security.schemes().stream().map(SecurityScheme::challenge).toList())
: "Bearer resource_metadata=\"" + req.origin() + metadataPath + "\"");
throw HttpException.unauthorized();
}));
if (config.requireTokenAudience()) {
chain.add(next -> (req, res) -> {
String resource = req.origin() + config.rootPath();
if (!SecurityIdentity.current().principal().hasAudience(resource)) {
log.warn("[flash-ext-mcp] Rejected a token not issued for {} (RFC 8707) — the authorization server must put it in aud", resource);
throw HttpException.forbidden();
}
return next.handle(req, res);
});
} else {
log.warn("[flash-ext-mcp] Token audience validation (RFC 8707) is DISABLED for {} — every token a registered issuer signs is accepted.", config.rootPath());
}
app.get(metadataPath, (req, res) -> {
List<String> issuers = issuers(security);
if (issuers.isEmpty()) throw HttpException.notFound("Protected resource metadata");
res.type(ContentType.JSON);
return McpResourceMetadata.build(req.origin() + config.rootPath(), issuers, config.scopesSupported());
});
}
private static List<String> issuers(SecurityExtension security) {
return security.schemes().stream().map(SecurityScheme::issuer).filter(Objects::nonNull).toList();
}
}
@@ -19,7 +19,7 @@ import java.nio.charset.StandardCharsets;
*
* <p>Not wired to {@code flash-ext-jackson} on purpose: the MCP JSON-RPC envelope is internal
* protocol plumbing, not a user-facing serialization concern, so this extension owns its
* mapper independently — same reasoning {@code flash-ext-oidc} applies to its own JSON needs
* mapper independently — the same reasoning any protocol-level extension applies to its own JSON needs
* (see {@code json-smart} there). See {@code docs/jackson-interop.md} for the full rationale
* and how a future opt-in reuse of a shared {@code ObjectMapper} could work.
*/
@@ -1,180 +0,0 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.oidc.Authenticated;
import dev.relism.flash.ext.oidc.ClaimsHolder;
import dev.relism.flash.ext.oidc.OidcMiddleware;
import dev.relism.flash.ext.oidc.OidcUser;
import dev.relism.flash.ext.oidc.RolesAllowed;
import dev.relism.flash.ext.oidc.ScopesAllowed;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.Request;
import dev.relism.flash.routing.Middleware;
import lombok.extern.slf4j.Slf4j;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Lazy, isolated bridge to {@code flash-ext-oidc}.
*
* <p>References to OIDC types only ever resolve when {@link #resolve}/{@link #compileToolPolicy}
* are actually invoked — never at {@link McpExtension} class-load time — because they live in
* this separate nested class. The caller wraps the invocation in {@code catch
* (NoClassDefFoundError)}, exactly like {@code OidcExtension}'s own lazy bridge to {@code
* flash-ext-openapi}. This is what lets {@code flash-ext-mcp} run standalone (MCP-only, no
* OAuth2) when {@code flash-ext-oidc} is not even on the classpath. {@link Resolved}/{@link
* McpAuthPolicy} carry only oidc-free types back out ({@link Middleware}, {@link String}, a
* {@link Function}, a {@link Supplier}) so no other class in this package ever has to reference
* an OIDC type.
*
* <p>Zero-config by design: when {@code flash-ext-oidc} is installed, everything an MCP OAuth2
* resource server needs — issuer, canonical resource identifier, RFC 8707 audience binding, and
* a spec-compliant {@code WWW-Authenticate} challenge (RFC 9728 §5.1) — is derived straight from
* the installed {@link OidcMiddleware}, with no additional {@link McpConfig} calls.
* {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)}
* remain as explicit overrides for the rare case where that guess is wrong.
*/
@Slf4j
final class McpOidcIntegration {
private static final String[] NO_VALUES = new String[0];
private McpOidcIntegration() {}
/** Everything {@link McpExtension} needs once oidc security is resolved. */
record Resolved(Middleware security, String issuer, String rolesClaimPath,
Function<Request, String> resourceIdentifier) {}
/** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
static Resolved resolve(FlashContext ctx, McpConfig config) {
Optional<OidcMiddleware> oidc = ctx.find(OidcMiddleware.class);
if (oidc.isEmpty()) return null;
OidcMiddleware oidcMw = oidc.get();
String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
String issuer = config.authorizationServerIssuer() != null
? config.authorizationServerIssuer() : oidcMw.issuer();
Function<Request, String> resourceId = req -> config.resourceIdentifier() != null
? config.resourceIdentifier()
: OidcMiddleware.selfOrigin(req, oidcMw.selfScheme()) + config.rootPath();
Middleware protect = oidcMw.protect(resourceMetadataPath);
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
return new Resolved(secured, issuer, oidcMw.rolesClaimPath(), resourceId);
}
/**
* RFC 8707 audience binding, unconditionally enforced once oidc is protecting the MCP
* route — no longer opt-in behind an explicit {@code resourceIdentifier(...)} call.
*/
private static Middleware audienceGuard(Function<Request, String> resourceIdentifier) {
return next -> (req, res) -> {
Map<String, Object> claims = ClaimsHolder.get();
String expected = resourceIdentifier.apply(req);
if (claims != null && !audienceMatches(claims.get("aud"), expected)) {
log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " +
"resource identifier \"{}\" — the authorization server must include this exact " +
"value in the access token's aud claim (e.g. an Audience protocol mapper in " +
"Keycloak) for this MCP server to accept it.", claims.get("aud"), expected);
throw HttpException.forbidden();
}
return next.handle(req, res);
};
}
private static boolean audienceMatches(Object aud, String expected) {
if (aud instanceof String s) return s.equals(expected);
if (aud instanceof Iterable<?> it) {
for (Object o : it) if (expected.equals(String.valueOf(o))) return true;
}
return false;
}
/**
* Compiles {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool class into a {@link
* McpAuthPolicy}, or returns {@code null} if the tool carries none of the three OIDC
* annotations. Called once per tool at boot ({@link McpRegistry#scan}), never on the
* request hot path — the {@link Supplier} it returns is what runs per {@code tools/call},
* closing over the already-normalized role/scope arrays so the hot path itself allocates
* nothing beyond what {@link OidcUser#hasRole}/{@link OidcUser#hasScope} already do.
*
* <p>Fails fast at boot, not silently at request time, for the two ways this can be
* misconfigured: the annotation present without OAuth2 actually protecting this MCP server
* ({@code oidcActive == false}), and {@code @Authenticated} — which has no per-tool meaning
* here (see below) — used at all.
*/
static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> toolClass, boolean oidcActive,
String rolesClaimPath) {
Authenticated auth = toolClass.getAnnotation(Authenticated.class);
RolesAllowed roles = toolClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = toolClass.getAnnotation(ScopesAllowed.class);
if (auth == null && roles == null && scopes == null) return null;
if (!oidcActive) {
throw new IllegalStateException(
"MCP tool \"" + toolClass.getSimpleName() + "\" declares @Authenticated/@RolesAllowed/" +
"@ScopesAllowed, but this MCP server has no active OAuth2 protection — flash-ext-oidc " +
"is not installed for it, or McpSecurity is NONE. These annotations require " +
"McpSecurity.AUTO/REQUIRED with an OidcExtension installed; install one, or remove the " +
"annotation from " + toolClass.getSimpleName() + ".");
}
if (auth != null) {
throw new IllegalStateException(
"MCP tool \"" + toolClass.getSimpleName() + "\" is annotated @Authenticated, which has " +
"no effect on an McpTool: the whole MCP endpoint is already all-or-nothing " +
"authenticated once oidc is active (McpSecurity.AUTO/REQUIRED) — unlike a RequestHandler " +
"route, there is no per-tool public/authenticated split to opt into. Remove it, or use " +
"@RolesAllowed/@ScopesAllowed to narrow further.");
}
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : NO_VALUES;
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : NO_VALUES;
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
Supplier<String> check = () -> {
OidcUser user = ClaimsHolder.user();
if (user == null) return "not authenticated";
if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles))
return "missing required role (any of: " + String.join(", ", requiredRoles) + ")";
if (requiredScopes.length > 0 && !hasScopes(user, requiredScopes, scopeMatch))
return "missing required scope (" + scopeMatch + " of: " + String.join(", ", requiredScopes) + ")";
return null;
};
return new McpAuthPolicy(check);
}
private static boolean hasAnyRole(OidcUser user, String claimPath, String[] roles) {
for (String role : roles) if (user.hasRole(claimPath, role)) return true;
return false;
}
private static boolean hasScopes(OidcUser user, String[] scopes, ScopesAllowed.Match match) {
if (match == ScopesAllowed.Match.ALL) {
for (String scope : scopes) if (!user.hasScope(scope)) return false;
return true;
}
for (String scope : scopes) if (user.hasScope(scope)) return true;
return false;
}
/** Mirrors {@code OidcAuthPolicy}'s own normalization — trim, dedupe, require non-blank. */
private static String[] normalizeRequired(String annotationName, String[] values) {
if (values == null || values.length == 0)
throw new IllegalStateException("@" + annotationName + " requires at least one value");
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
for (String raw : values) {
if (raw == null) continue;
String trimmed = raw.trim();
if (!trimmed.isEmpty()) normalized.add(trimmed);
}
if (normalized.isEmpty())
throw new IllegalStateException("@" + annotationName + " requires at least one non-empty value");
return normalized.toArray(String[]::new);
}
}
@@ -2,6 +2,8 @@ package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonGenerator;
import dev.relism.flash.exceptions.InitializationException;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityPolicy;
import dev.relism.flash.extension.FlashContext;
import java.io.IOException;
@@ -25,7 +27,7 @@ final class McpRegistry {
private static final String EMPTY_ARRAY = "[]";
/** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */
record RegisteredTool(String name, McpTool instance, McpAuthPolicy policy) {}
record RegisteredTool(String name, McpTool instance, SecurityPolicy policy) {}
record RegisteredResource(String uri, McpResource instance) {}
record RegisteredPrompt(String name, McpPrompt instance) {}
@@ -39,15 +41,8 @@ final class McpRegistry {
private McpRegistry() {}
/**
* @param oidcActive whether this MCP server's route is actually OAuth2-protected right
* now (see {@link McpOidcIntegration#resolve}) — gates whether
* {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or
* rejected at boot as a misconfiguration; see
* {@link McpOidcIntegration#compileToolPolicy}.
* @param rolesClaimPath claim path resolved from the installed OIDC extension.
*/
static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) {
/** @param security {@code null} for a server running with {@link McpSecurity#NONE} */
static McpRegistry scan(String packageName, FlashContext ctx, SecurityExtension security) {
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
McpRegistry registry = new McpRegistry();
@@ -55,7 +50,9 @@ final class McpRegistry {
Tool ann = cls.getAnnotation(Tool.class);
McpTool instance = instantiate(cls);
instance.bind(ctx);
McpAuthPolicy policy = compileToolPolicy(cls, oidcActive, rolesClaimPath);
if (security == null && SecurityPolicy.of(cls) != null)
throw new InitializationException("MCP tool \"" + ann.name() + "\" declares security annotations, but the server runs with McpSecurity.NONE");
SecurityPolicy policy = security == null ? null : security.policy(cls);
if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null)
throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\"");
}
@@ -173,24 +170,6 @@ final class McpRegistry {
gen.writeEndArray();
}
/**
* Isolated the same way {@link McpOidcIntegration#resolve} is — {@code
* NoClassDefFoundError} here means {@code flash-ext-oidc} genuinely isn't on the runtime
* classpath, in which case a tool couldn't have been compiled against
* {@code @RolesAllowed}/{@code @ScopesAllowed} in the first place, so there's nothing to
* check (and nothing lost: {@code oidcActive} is only ever {@code true} once {@link
* McpOidcIntegration#resolve} has already succeeded once this boot, which proves those
* types resolve fine).
*/
private static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> cls, boolean oidcActive,
String rolesClaimPath) {
try {
return McpOidcIntegration.compileToolPolicy(cls, oidcActive, rolesClaimPath);
} catch (NoClassDefFoundError e) {
return null;
}
}
private static <T> T instantiate(Class<T> cls) {
try {
Constructor<T> ctor = cls.getDeclaredConstructor();
@@ -2,18 +2,18 @@ package dev.relism.flash.ext.mcp;
import java.util.List;
/** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */
/** RFC 9728 OAuth 2.0 Protected Resource Metadata. */
final class McpResourceMetadata {
private McpResourceMetadata() {}
/** {@code scopesSupported} is optional per RFC 9728 — omitted from the document if empty. */
static String build(String resourceIdentifier, String authorizationServerIssuer, List<String> scopesSupported) {
/** {@code scopesSupported} is optional — omitted when empty. */
static String build(String resource, List<String> authorizationServers, List<String> scopesSupported) {
return McpJson.buildString(gen -> {
gen.writeStartObject();
gen.writeStringField("resource", resourceIdentifier);
gen.writeStringField("resource", resource);
gen.writeArrayFieldStart("authorization_servers");
gen.writeString(authorizationServerIssuer);
for (String issuer : authorizationServers) gen.writeString(issuer);
gen.writeEndArray();
if (!scopesSupported.isEmpty()) {
gen.writeArrayFieldStart("scopes_supported");
@@ -1,17 +1,11 @@
package dev.relism.flash.ext.mcp;
/**
* OAuth2 requirement policy for the MCP endpoint, resolved against whether
* {@code flash-ext-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
*/
/** Whether the MCP endpoint requires an authenticated caller. */
public enum McpSecurity {
/** Fail fast at boot if {@code flash-ext-oidc} is not installed — never expose an unprotected MCP endpoint. */
/** The default: every call is authenticated by {@code flash-ext-security-core}, which must be installed. */
REQUIRED,
/** Protect the endpoint if {@code flash-ext-oidc} is installed; otherwise run unprotected and log a warning. */
AUTO,
/** Never protect the endpoint, even if {@code flash-ext-oidc} is installed elsewhere in the app. */
/** A public endpoint. Tools declaring security annotations fail the boot. */
NONE
}
@@ -19,7 +19,7 @@ final class McpTransportGuards {
* allowed through — only a <em>present but disallowed</em> value is rejected.
*
* <p>If {@code allowedOrigins} is empty, validation is skipped and a boot-time warning is
* logged — same graceful-degradation shape as {@link McpSecurity#AUTO}.
* logged.
*/
static Middleware originGuard(List<String> allowedOrigins) {
if (allowedOrigins.isEmpty()) {
@@ -38,7 +38,7 @@ final class McpTransportGuards {
/**
* Safety net around the whole MCP route: translates {@link HttpException} (thrown by
* {@link #originGuard} or by {@code flash-ext-oidc}'s middleware) into a proper HTTP status
* {@link #originGuard} or by {@code flash-ext-security-core}) into a proper HTTP status
* directly, instead of relying on the app's global exception handler — which defaults to a
* generic 500 for every exception type unless the app owner overrides it (see
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint
@@ -1,109 +0,0 @@
package dev.relism.flash.ext.mcp;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Minimal, self-contained fake OIDC provider for tests: real discovery document, real JWKS
* endpoint, real RS256-signed tokens — no network dependency beyond localhost, no mocking
* framework. Exercises {@code flash-ext-oidc}'s actual discovery + JWKS + JWT validation path.
*/
final class FakeOidcProvider implements AutoCloseable {
private final HttpServer server;
private final String issuer;
private final RSAKey rsaKey;
FakeOidcProvider() throws Exception {
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
gen.initialize(2048);
KeyPair kp = gen.generateKeyPair();
this.rsaKey = new RSAKey.Builder((RSAPublicKey) kp.getPublic())
.privateKey((RSAPrivateKey) kp.getPrivate())
.keyUse(KeyUse.SIGNATURE)
.algorithm(JWSAlgorithm.RS256)
.keyID(UUID.randomUUID().toString())
.build();
this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
this.issuer = "http://127.0.0.1:" + server.getAddress().getPort();
server.createContext("/.well-known/openid-configuration", ex -> respond(ex, discoveryDocument()));
server.createContext("/jwks", ex -> respond(ex, new JWKSet(rsaKey.toPublicJWK()).toJSONObject().toString()));
server.setExecutor(null);
server.start();
}
String issuer() { return issuer; }
/** Mints a valid RS256 access token — bearer-validation only, no full authorization-code round-trip needed. */
String signToken(String subject, String audience) {
return signToken(subject, audience, null, NO_ROLES);
}
/**
* Same as {@link #signToken(String, String)}, plus a {@code scope} claim (space-delimited,
* matching {@link dev.relism.flash.ext.oidc.OidcUser#hasScope}'s default claim path) and a
* Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default
* {@code rolesClaimPath}) when {@code roles} is non-empty.
*/
String signToken(String subject, String audience, String scope, String... roles) {
try {
JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
.issuer(issuer)
.subject(subject)
.audience(audience)
.issueTime(Date.from(Instant.now()))
.expirationTime(Date.from(Instant.now().plusSeconds(300)));
if (scope != null) builder.claim("scope", scope);
if (roles.length > 0) builder.claim("realm_access", Map.of("roles", List.of(roles)));
SignedJWT jwt = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), builder.build());
jwt.sign(new RSASSASigner(rsaKey));
return jwt.serialize();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private static final String[] NO_ROLES = new String[0];
private String discoveryDocument() {
return "{"
+ "\"issuer\":\"" + issuer + "\","
+ "\"authorization_endpoint\":\"" + issuer + "/auth\","
+ "\"token_endpoint\":\"" + issuer + "/token\","
+ "\"jwks_uri\":\"" + issuer + "/jwks\""
+ "}";
}
private static void respond(com.sun.net.httpserver.HttpExchange ex, String body) throws java.io.IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().add("Content-Type", "application/json");
ex.sendResponseHeaders(200, bytes.length);
try (OutputStream os = ex.getResponseBody()) { os.write(bytes); }
}
@Override
public void close() { server.stop(0); }
}
@@ -1,141 +0,0 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.oidc.OidcConfig;
import dev.relism.flash.ext.oidc.OidcExtension;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} — see
* {@link McpOidcIntegration#compileToolPolicy}. Same real-discovery/real-JWKS/real-RS256-token
* approach as {@link McpExtensionSecurityTest}, against {@code fixtures.secured}'s tools.
*/
class McpAuthPolicyTest {
private static final String SECURED_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.secured";
private static final String AUTHENTICATED_ONLY_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.authenticatedonly";
private static final FakeOidcProvider provider = newProvider();
@RegisterExtension
static FlashTest secured = FlashTest.of(app -> {
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(SECURED_TOOLS)
.security(McpSecurity.REQUIRED)
.build()));
});
/** Tokens are audience-bound to this server, so the port has to be read back after boot. */
private static String resourceId() {
return "http://127.0.0.1:" + secured.port() + "/mcp";
}
@AfterAll
static void closeProvider() {
provider.close();
}
// ── Tool policy ──────────────────────────────────────────────────────────
@Test
void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception {
callTool("admin_only", provider.signToken("user-1", resourceId(), null))
.expectStatus(200)
.expectBodyContains("\"isError\":true")
.expectBodyContains("missing required role");
callTool("admin_only", provider.signToken("user-1", resourceId(), null, "admin"))
.expectStatus(200)
.expectBodyContains("\"isError\":false")
.expectBodyContains("ok");
}
@Test
void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception {
callTool("write_only", provider.signToken("user-1", resourceId(), "read"))
.expectStatus(200)
.expectBodyContains("\"isError\":true")
.expectBodyContains("missing required scope");
callTool("write_only", provider.signToken("user-1", resourceId(), "read write"))
.expectStatus(200)
.expectBodyContains("\"isError\":false")
.expectBodyContains("written");
}
@Test
void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception {
callTool("open", provider.signToken("user-1", resourceId(), null))
.expectStatus(200)
.expectBodyContains("\"isError\":false")
.expectBodyContains("open");
}
private static FlashResponse callTool(String toolName, String token) {
return secured.request()
.header("Accept", "application/json")
.header("Authorization", "Bearer " + token)
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\""
+ toolName + "\"}}")
.post("/mcp");
}
// ── Boot-time rejection ──────────────────────────────────────────────────
// These assert that start() throws, so they build the app directly rather than through
// FlashTest — a harness whose job is to boot an app is the wrong tool for asserting that
// booting fails. Port 0 still removes the old free-port dance.
private FlashApp bootFailure;
@AfterEach
void releaseBootFailureListener() {
if (bootFailure != null) bootFailure.stop().join();
}
@Test
void toolAnnotated_butSecurityNone_failsAtBoot() {
bootFailure = mcpApp(SECURED_TOOLS, McpSecurity.NONE);
IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start);
assertTrue(error.getMessage().contains("no active OAuth2 protection"), error.getMessage());
}
@Test
void bareAuthenticated_hasNoEffect_failsAtBoot() {
bootFailure = mcpApp(AUTHENTICATED_ONLY_TOOLS, McpSecurity.REQUIRED);
IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start);
assertTrue(error.getMessage().contains("no effect"), error.getMessage());
}
private static FlashApp mcpApp(String toolsPackage, McpSecurity security) {
FlashApp app = FlashApp.create(FlashConfiguration.builder()
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(toolsPackage)
.security(security)
.build()));
return app;
}
private static FakeOidcProvider newProvider() {
try {
return new FakeOidcProvider();
} catch (Exception failure) {
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
}
}
}
@@ -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);
}
}
@@ -1,179 +0,0 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.oidc.OidcConfig;
import dev.relism.flash.ext.oidc.OidcExtension;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashApplication;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.testing.FlashRequest;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-oidc}
* installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
* tokens — plus the fail-fast/degrade behavior when oidc is absent.
*
* <p>Four server configurations differ only in how MCP security is declared, so each gets its
* own {@link FlashTest} and they share one provider.
*/
class McpExtensionSecurityTest {
private static final String TOOLS_PACKAGE = "dev.relism.flash.ext.mcp.fixtures";
private static final String EXPLICIT_RESOURCE_ID = "https://mcp.example.com/mcp";
private static final FakeOidcProvider provider = newProvider();
/** MCP asked for AUTO security with no oidc installed — should degrade to public. */
@RegisterExtension
static FlashTest degraded = FlashTest.of(app -> app.install(new McpExtension(
McpConfig.builder("auto-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.AUTO)
.build())));
/** REQUIRED with oidc, resource identifier derived from the request. */
@RegisterExtension
static FlashTest secured = FlashTest.of(securedApp(null, null));
/** REQUIRED with oidc and an explicitly declared resource identifier. */
@RegisterExtension
static FlashTest securedWithResourceId = FlashTest.of(securedApp(EXPLICIT_RESOURCE_ID, null));
/** REQUIRED with oidc and advertised scopes. */
@RegisterExtension
static FlashTest securedWithScopes =
FlashTest.of(securedApp(null, new String[] {"openid", "profile", "email"}));
@AfterAll
static void closeProvider() {
provider.close();
}
// ── No oidc installed ────────────────────────────────────────────────────
@Test
void required_withoutOidc_throwsAtBoot() {
// Asserting that boot fails, so this one builds its app directly rather than through
// the harness; port(0) still removes the old free-port dance.
FlashApp app = FlashApp.create(FlashConfiguration.builder()
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED)
.build()));
try {
assertThrows(IllegalStateException.class, app::start);
} finally {
app.stop().join();
}
}
@Test
void auto_withoutOidc_degradesToPublic() {
post(degraded, initializeBody(), null).expectStatus(200);
}
// ── REQUIRED with oidc ───────────────────────────────────────────────────
@Test
void required_withOidc_rejectsMissingToken() {
post(secured, initializeBody(), null).expectStatus(401);
}
@Test
void required_withOidc_rejectsWrongAudience() throws Exception {
String token = provider.signToken("user-1", "https://someone-else.example.com/resource");
post(securedWithResourceId, initializeBody(), token).expectStatus(403);
}
@Test
void required_withOidc_acceptsValidAudience() throws Exception {
String token = provider.signToken("user-1", EXPLICIT_RESOURCE_ID);
post(securedWithResourceId, initializeBody(), token)
.expectStatus(200)
.expectBodyContains("\"protocolVersion\"");
}
@Test
void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception {
String derivedResourceId = "http://127.0.0.1:" + secured.port() + "/mcp";
post(secured, initializeBody(), provider.signToken("user-1", derivedResourceId))
.expectStatus(200);
post(secured, initializeBody(), provider.signToken("user-1", "https://someone-else.example.com/resource"))
.expectStatus(403);
}
@Test
void required_withOidc_missingToken_challengeIncludesResourceMetadata() {
FlashResponse response = post(secured, initializeBody(), null).expectStatus(401);
String challenge = response.header("WWW-Authenticate");
assertTrue(challenge != null && challenge.contains("resource_metadata=\"http://127.0.0.1:"
+ secured.port() + "/.well-known/oauth-protected-resource/mcp\""),
"WWW-Authenticate: " + challenge);
}
// ── Protected resource metadata ──────────────────────────────────────────
@Test
void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() {
FlashResponse response = secured.get("/.well-known/oauth-protected-resource/mcp")
.expectStatus(200)
.expectBodyContains("\"resource\":\"http://127.0.0.1:" + secured.port() + "/mcp\"")
.expectBodyContains("\"authorization_servers\":[\"" + provider.issuer() + "\"]");
assertTrue(!response.body().contains("scopes_supported"),
"scopes_supported must be omitted when unset: " + response.body());
}
@Test
void scopesSupported_published_inProtectedResourceMetadata() {
securedWithScopes.get("/.well-known/oauth-protected-resource/mcp")
.expectStatus(200)
.expectBodyContains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]");
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static FlashApplication securedApp(String resourceIdentifier, String[] scopesSupported) {
return app -> {
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
McpConfig.Builder mcp = McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED);
if (resourceIdentifier != null) mcp.resourceIdentifier(resourceIdentifier);
if (scopesSupported != null) mcp.scopesSupported(scopesSupported);
app.install(new McpExtension(mcp.build()));
};
}
private static FlashResponse post(FlashTest server, String body, String bearerToken) {
FlashRequest request = server.request().header("Accept", "application/json").json(body);
if (bearerToken != null) request.header("Authorization", "Bearer " + bearerToken);
return request.post("/mcp");
}
private static String initializeBody() {
return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}";
}
private static FakeOidcProvider newProvider() {
try {
return new FakeOidcProvider();
} catch (Exception failure) {
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
}
}
}
@@ -16,7 +16,7 @@ class McpRegistryTest {
@Test
void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception {
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), false, "realm_access.roles");
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), null);
assertTrue(registry.hasTools());
assertTrue(registry.hasResources());
@@ -47,7 +47,7 @@ class McpRegistryTest {
@Test
void scan_emptyPackage_throwsInitializationException() {
assertThrows(InitializationException.class,
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), false, "realm_access.roles"));
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), null));
}
private static JsonNode findByField(JsonNode array, String field, String value) {
@@ -0,0 +1,109 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.apikey.ApiKey;
import dev.relism.flash.ext.security.apikey.ApiKeyExtension;
import dev.relism.flash.ext.security.apikey.GeneratedApiKey;
import dev.relism.flash.ext.security.oidc.OidcExtension;
import dev.relism.flash.ext.security.oidc.OidcProvider;
import dev.relism.flash.ext.security.test.FakeOidcProvider;
import dev.relism.flash.testing.FlashRequest;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.util.Map;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.assertThrows;
class McpSecurityTest {
static final FakeOidcProvider provider = start();
static final GeneratedApiKey KEY = new ApiKeyExtension<String>("mk", id -> null).generate();
static final ApiKeyExtension<String> apiKeys = new ApiKeyExtension<>("mk", id -> id.equals(KEY.id()) ? new ApiKey<>(KEY.id(), KEY.secretHash(), "agent", null, null) : null);
@RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash
.install(new SecurityExtension().roles((identity, role, on) -> identity.principal().name().equals(role + "@" + on.get("project"))))
.install(new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret")))
.install(apiKeys)
.install(new McpExtension(McpConfig.builder("secure").toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured")
.scopesSupported("openid", "email").build())));
/** The same chain with the RFC 8707 check turned off, for an authorization server that cannot mint a resource audience. */
@RegisterExtension
static final FlashTest relaxed = FlashTest.of(flash -> flash
.install(new SecurityExtension().roles((identity, role, on) -> false))
.install(new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret")))
.install(new McpExtension(McpConfig.builder("relaxed").toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured")
.requireTokenAudience(false).build())));
static FakeOidcProvider start() {
try {
return new FakeOidcProvider();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
static String resource() {
return "http://127.0.0.1:" + app.port() + "/mcp";
}
static FlashResponse call(Consumer<FlashRequest> credential, String method, String params) {
return app.request().with(credential).header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"" + method + "\",\"params\":" + params + "}")
.post("/mcp");
}
@Test
void anAnonymousCallIsChallengedWithTheResourceMetadata() {
call(request -> {}, "initialize", "{}").expectStatus(401)
.expectHeader("WWW-Authenticate", "Bearer resource_metadata=\"http://127.0.0.1:" + app.port() + "/.well-known/oauth-protected-resource/mcp\"");
}
@Test
void theProtectedResourceMetadataNamesTheIssuer() {
app.get("/.well-known/oauth-protected-resource/mcp").expectStatus(200)
.expectBody("{\"resource\":\"" + resource() + "\",\"authorization_servers\":[\"" + provider.issuer() + "\"],\"scopes_supported\":[\"openid\",\"email\"]}");
}
@Test
void aTokenIsAcceptedOnlyForThisResource() {
call(provider.bearer("u", Map.of("aud", resource())), "initialize", "{}").expectStatus(200).expectBodyContains("protocolVersion");
call(provider.bearer("u", Map.of("aud", "https://elsewhere.example/mcp")), "initialize", "{}").expectStatus(403);
}
@Test
void aTokenWithoutTheResourceAudienceIsAcceptedWhenTheCheckIsOff() {
relaxed.request().with(provider.bearer("u", Map.of("aud", "https://elsewhere.example/mcp"))).header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
.post("/mcp").expectStatus(200).expectBodyContains("protocolVersion");
}
/** An API key is not audience-bound: the same chain authenticates agents that never saw an authorization server. */
@Test
void anApiKeyIsAcceptedBesideOAuth() {
call(request -> request.header("Authorization", "Bearer " + KEY.token()), "initialize", "{}").expectStatus(200);
}
@Test
void toolPoliciesReadTheirTargetFromTheArguments() {
Consumer<FlashRequest> admin = provider.bearer("admin@42", Map.of("aud", resource()));
call(admin, "tools/call", "{\"name\":\"admin_only\",\"arguments\":{\"project\":\"42\"}}").expectBodyContains("\"isError\":false");
call(admin, "tools/call", "{\"name\":\"admin_only\",\"arguments\":{\"project\":\"7\"}}").expectBodyContains("denied: missing role");
call(provider.bearer("u", Map.of("aud", resource(), "scope", "write")), "tools/call", "{\"name\":\"write_only\"}").expectBodyContains("written");
call(provider.bearer("u", Map.of("aud", resource())), "tools/call", "{\"name\":\"write_only\"}").expectBodyContains("denied: missing scope");
}
@Test
void securityIsRequiredUnlessDeclaredOff() {
FlashTest unsecured = FlashTest.of(flash -> flash.install(new McpExtension(McpConfig.builder("x").toolsPackage("dev.relism.flash.ext.mcp.fixtures").build())));
assertThrows(Exception.class, () -> unsecured.get("/mcp"));
FlashTest contradictory = FlashTest.of(flash -> flash.install(new McpExtension(McpConfig.builder("x")
.toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured").security(McpSecurity.NONE).build())));
assertThrows(Exception.class, () -> contradictory.get("/mcp"));
}
}
@@ -1,20 +0,0 @@
package dev.relism.flash.ext.mcp.authfixtures.authenticatedonly;
import dev.relism.flash.ext.mcp.McpTool;
import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.oidc.Authenticated;
/** Deliberately misconfigured fixture: bare @Authenticated has no effect on an McpTool — see
* McpOidcIntegration#compileToolPolicy. Boot must fail with a clear message, not silently no-op. */
@Tool(name = "pointless", description = "Exists only to prove @Authenticated alone fails boot")
@Authenticated
public class PointlessAuthTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent("unreachable"));
}
}
@@ -5,10 +5,10 @@ import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.oidc.RolesAllowed;
import dev.relism.flash.ext.security.RolesAllowed;
@Tool(name = "admin_only", description = "Only callable with the admin role")
@RolesAllowed("admin")
@RolesAllowed(value = "admin", on = "project")
public class AdminOnlyTool extends McpTool {
@Override
@@ -5,7 +5,7 @@ import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.oidc.ScopesAllowed;
import dev.relism.flash.ext.security.ScopesAllowed;
@Tool(name = "write_only", description = "Only callable with the write scope")
@ScopesAllowed("write")
-462
View File
@@ -1,462 +0,0 @@
# flash-ext-oidc
Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
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).
## What it provides
| Component | Description |
|---|---|
| `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 |
| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
## Dependencies
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
```
Transitive: `nimbus-jose-jwt`, `json-smart`.
Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically.
## Installation
```java
FlashApp.create(8080)
.install(new JacksonExtension())
.install(new OpenApiExtension(...)) // optional — enables Swagger security
.install(new OidcExtension(
OidcConfig.builder(
"https://idp.example.com",
"my-client", "my-secret", "/auth/callback")
.build()
))
.start();
```
Install order is irrelevant. The two-phase extension model guarantees all services
(including `OpenApiSecurityRegistry` from `flash-ext-openapi`) are registered before
any extension's routes phase runs.
### Keycloak shortcut
```java
OidcConfig.keycloak(
"https://keycloak.example.com", // server URL (no realm)
"myrealm", // realm
"my-client", "my-secret", // client credentials
"/auth/callback") // redirect URI (server-relative)
.https() // behind TLS
.build()
```
`keycloak()` pre-sets `rolesClaimPath("realm_access.roles")` and constructs the issuer as
`{serverUrl}/realms/{realm}`.
### Authelia / generic IdP
```java
OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/callback")
.rolesClaimPath("groups")
.build()
```
## OidcConfig reference
### Required fields
| Field | Description |
|---|---|
| `issuer` | Provider base URL — also used for OIDC discovery |
| `clientId` | OAuth2 client ID |
| `clientSecret` | OAuth2 client secret |
| `redirectUri` | Callback URI; server-relative paths (starting with `/`) are resolved at request time |
### Builder options
| Method | Default | Description |
|---|---|---|
| `.scopes("openid profile email")` | `"openid profile email"` | Space-separated requested scopes |
| `.routePrefix("/auth")` | `"/auth"` | Prefix for login/callback/logout routes |
| `.selfScheme("http")` | `"http"` | Scheme used when resolving server-relative redirect URIs |
| `.https()` | — | Shorthand for `.selfScheme("https")` |
| `.rolesClaimPath("realm_access.roles")` | `"realm_access.roles"` | Dot-path to the roles array in JWT claims |
| `.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) |
| `.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 |
### Environment variables (`OidcConfig.fromEnv()`)
```
OIDC_ISSUER required
OIDC_CLIENT_ID required
OIDC_CLIENT_SECRET required
OIDC_REDIRECT_URI required e.g. /auth/callback
OIDC_SCOPES default: openid profile email
OIDC_ROUTE_PREFIX default: /auth
OIDC_SELF_SCHEME default: http
OIDC_ROLES_CLAIM default: realm_access.roles
OIDC_SCOPE_CLAIMS default: scope,scp
OIDC_ALGORITHM default: RS256
OIDC_POST_LOGOUT_REDIRECT default: /
OIDC_CLIENT_AUTH_METHOD default: POST
```
## Protecting routes
### Class-based handlers (annotations)
```java
@Route(method = HttpMethod.GET, path = "/me")
@Authenticated
public class MePage extends JacksonHandler {
@Override
public Object handle(Request req, Response res) {
OidcUser u = ClaimsHolder.user();
return json(res, Map.of("sub", u.sub(), "email", u.email()));
}
}
@Route(method = HttpMethod.GET, path = "/admin")
@RolesAllowed("admin") // OR semantics: "admin" OR "superuser"
// @RolesAllowed({"admin", "superuser"})
public class AdminPage extends JacksonHandler { ... }
@Route(method = HttpMethod.POST, path = "/orders")
@ScopesAllowed("orders:write") // default = ALL semantics
public class CreateOrder extends JacksonHandler { ... }
@Route(method = HttpMethod.POST, path = "/payments")
@ScopesAllowed(value = {"payments:write", "payments:admin"}, match = ScopesAllowed.Match.ANY)
public class PayOrder extends JacksonHandler { ... }
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
@RolesAllowed("admin")
@ScopesAllowed("users:delete") // combined with AND semantics
public class DeleteUser extends JacksonHandler { ... }
```
The middleware is injected automatically by the annotation processor — no manual wiring needed.
Annotation composition rules:
- `@Authenticated` requires auth only
- `@RolesAllowed` implies authentication + role OR-check
- `@ScopesAllowed` implies authentication + scope check (`ALL`/`ANY`)
- combining `@RolesAllowed` + `@ScopesAllowed` uses AND semantics
- `@Authenticated(optional = true)` cannot be combined with role/scope constraints
### Lambda routes (manual middleware)
For lambda routes, pass the middleware as a varargs argument. Retrieve `OidcMiddleware`
from the context inside another extension's `routes()` phase, or after `start()`:
```java
OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
// Authentication only
app.get("/api/me", (req, res) -> {
OidcUser u = ClaimsHolder.user(); // never null here
return Map.of("sub", u.sub(), "email", u.email());
}, oidc.protect());
// Authentication + role check
app.delete("/api/admin/users/{id}", (req, res) -> {
OidcUser u = ClaimsHolder.user();
// ...
}, oidc.requireRole("admin"));
// Multiple roles (OR): passes if user holds any one of them
app.get("/api/reports", (req, res) -> { ... }, oidc.requireRole("admin", "reports-viewer"));
// Require all listed scopes
app.post("/api/orders", (req, res) -> { ... }, oidc.requireScopes("orders:write", "payments:write"));
// Require at least one listed scope
app.post("/api/payments", (req, res) -> { ... }, oidc.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
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
own virtual thread, so `ThreadLocal` values are naturally isolated).
### OidcUser (preferred)
```java
OidcUser u = ClaimsHolder.user(); // never null inside a protected handler
String sub = u.sub(); // unique user ID
String email = u.email();
String username = u.username(); // preferred_username
String name = u.name(); // full display name
// Roles — pass the dot-path matching your provider's claim structure
List<String> roles = u.roles("realm_access.roles"); // Keycloak realm roles
List<String> clientRoles = u.roles("resource_access.my-client.roles"); // Keycloak client roles
List<String> groups = u.roles("groups"); // Authelia
boolean isAdmin = u.hasRole("realm_access.roles", "admin");
// Scopes (OIDC/OAuth2 generic): checks "scope" then "scp"
List<String> scopes = u.scopes();
boolean canWrite = u.hasScope("orders:write");
// Custom claim path resolution (for provider-specific payloads)
List<String> customScopes = u.scopes("scope,scp,permissions.scopes");
boolean canApprove = u.hasScope("permissions.scopes", "orders:approve");
// Arbitrary claim
String locale = (String) u.claim("locale");
Long exp = u.claim("exp", Long.class);
// Full raw map (escape hatch)
Map<String, Object> all = u.claims();
```
### Raw access (escape hatch)
```java
Map<String, Object> claims = ClaimsHolder.get();
String email = ClaimsHolder.claim("email");
```
## Performance
The middleware adds negligible overhead on the hot path for authenticated requests:
| Step | Cost |
|---|---|
| `Authorization` header check | `O(1)` map lookup |
| Cookie parse | `O(cookie_length)` single pass scan |
| Session lookup | `O(1)` `ConcurrentHashMap.get()` |
| Token expiry check | `O(1)` `Instant` comparison |
| `ClaimsHolder.set()` | `O(1)` `ThreadLocal.set()` |
No network calls, no cryptography, no JSON parsing on the happy path (valid session).
JWKS key fetching only happens for Bearer token validation and is cached + rate-limited by
Nimbus's `JWKSourceBuilder`. Silent token refresh only triggers when the access token expires.
Role/scope claim paths are compiled once during middleware construction (mount time), not per request.
## Authentication flow details
On each request the middleware resolves credentials in this order:
1. **Bearer token** (`Authorization: Bearer <jwt>`) — validated against JWKS.
2. **Session cookie** (`oidc_session`) — looked up in the session store; transparently
refreshed if the access token is expired (silent refresh via refresh token).
3. **No valid credentials**:
- Browser clients (no `Accept: application/json`) → redirect to `{prefix}/login?redirect={path}`
- API clients → `401 Unauthorized`
### API error semantics (RFC 6750)
For API clients (`Accept: application/json`) the middleware includes `WWW-Authenticate`:
- missing credentials: `Bearer realm="<schemeName>"`
- invalid bearer token: `Bearer realm="<schemeName>", error="invalid_token"`
- insufficient scopes: `Bearer realm="<schemeName>", error="insufficient_scope", scope="<required scopes>"`
This enables interoperable client-side handling and proper OAuth2 challenge semantics.
### Token validation (OIDC Core §3.1.3.7)
| Check | Access token | ID token |
|---|---|---|
| Signature (JWKS) | yes | yes |
| `iss` | yes | yes |
| `aud` = clientId | no (varies by provider) | yes |
| `exp`, `iat`, `sub` | yes | yes |
| `nonce` | — | yes |
JWKS keys are cached, rate-limited, and retried on cache-miss (handles key rotation).
### Claim merge strategy
At callback time the extension merges access token + ID token claims:
- Access token claims first (contains provider-specific data like `realm_access.roles`)
- ID token claims override (contains verified identity: `sub`, `email`, `name`, …)
This is provider-agnostic: authorization claims live in the AT per RFC 9068,
identity claims live in the IT per OIDC Core.
## Standards & compliance notes
This extension is designed to be compliant with the most relevant OIDC/OAuth2 RFCs:
- RFC 8414 (Authorization Server Metadata): discovery via `/.well-known/openid-configuration`
- OpenID Connect Core 1.0: Authorization Code flow + PKCE + `nonce` validation on ID token
- RFC 7636 (PKCE): S256 challenge/verifier flow
- RFC 6750 (Bearer Token Usage): `WWW-Authenticate` challenges with standard error codes
- RFC 9068 (JWT Profile for Access Tokens): JWT bearer access-token validation path
- RFC 7519 / RFC 7517 / RFC 7515 family: JWT/JWK/JWS validation via Nimbus + JWKS caching/rotation
Provider interoperability details:
- scope extraction supports both standard forms: `scope` (space-delimited string) and `scp` (list/string)
- roles remain configurable via `rolesClaimPath` (`realm_access.roles`, `groups`, etc.)
- scope claim fallback chain is configurable via `scopeClaimPaths`
## Testing scopes with Keycloak
Quick path to test `@ScopesAllowed` end-to-end:
1. **Create a client scope**
- Realm -> Client scopes -> Create
- Name: `orders:write` (or any scope name you want to enforce)
2. **Attach it to your client**
- Clients -> `<your-client>` -> Client scopes
- Add the scope as `Default` (always in token) or `Optional` (requested via `scope` param)
3. **Ensure scope mapper reaches the token**
- For most Keycloak setups this is automatic via built-in `microprofile-jwt`/scope mappers
- Verify the access token contains either `scope` string or `scp` list
4. **Request the scope in Flash config**
- 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
6. **Verify behavior**
- token with scope -> 200
- token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope`
Useful token inspection flow while testing:
- Obtain a token from Keycloak
- Decode payload (`jwt.io` or local tool)
- check `scope` / `scp` claims
- call your protected endpoint and inspect status + `WWW-Authenticate`
## Session store
The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments.
For clustered deployments, implement `OidcSessionStore`:
```java
public interface OidcSessionStore {
void save(OidcSession session);
Optional<OidcSession> find(String sessionId);
void delete(String sessionId);
}
```
```java
OidcConfig.builder(...)
.sessionStore(new RedisOidcSessionStore(redisClient))
.build()
```
`OidcSession` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map).
## Logout
Add a logout button anywhere in your UI — a `<form>` is sufficient (no JavaScript needed):
```html
<form method="POST" action="/auth/logout">
<button type="submit">Logout</button>
</form>
```
The `POST {prefix}/logout` handler:
1. Reads the `oidc_session` cookie, looks up the session, retrieves the `id_token`.
2. Deletes the local session and clears the cookie (`Max-Age=0`).
3. If the provider has an `end_session_endpoint` (standard IdPs do), redirects there with
`?id_token_hint=<idToken>&post_logout_redirect_uri=<postLogoutRedirectUri>` — this logs
the user out of the IdP as well.
4. Otherwise redirects to `postLogoutRedirectUri` (default: `/`).
## Bearer token (API clients)
For API-to-API or SPA-to-API calls, pass a Bearer access token directly. The middleware
validates the JWT signature against JWKS and extracts the claims — no session involved:
```
Authorization: Bearer <access_token>
```
The token must be a JWT (opaque tokens are not supported). Claims are available via
`ClaimsHolder.user()` as usual.
## Multi-tenant
Multiple OIDC providers on one server — each `OidcExtension` instance is fully independent
(its own PKCE state store, session store, validator, and middleware):
```java
OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", "clientA", "secretA", "/a/auth/callback")
.routePrefix("/a/auth").schemeName("tenantA").build();
OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", "clientB", "secretB", "/b/auth/callback")
.routePrefix("/b/auth").schemeName("tenantB").build();
app.install(new OidcExtension(tenantA))
.install(new OidcExtension(tenantB));
```
To reference a specific tenant's middleware on lambda routes, keep the extension instances
and retrieve `OidcMiddleware` from context after `start()`:
```java
OidcExtension extA = new OidcExtension(tenantA);
OidcExtension extB = new OidcExtension(tenantB);
FlashApp app = FlashApp.create(8080)
.install(extA)
.install(extB)
.start()
.join(); // wait for bind
OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // last registered = tenantB
```
> **Note:** because both extensions register `OidcMiddleware.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()`.
Class-based handlers annotated with `@Authenticated` / `@RolesAllowed` get the last
registered processor's middleware. For true multi-tenant class-based routing, install
tenant-specific annotation processors with different annotations.
## OpenAPI integration
If `flash-ext-openapi` is on the classpath and installed (order irrelevant),
the extension automatically:
- Adds a `components.securitySchemes` entry for the provider (OAuth2, authorizationCode flow)
- Adds `security` requirements to every operation whose handler carries `@Authenticated`
, `@RolesAllowed`, or `@ScopesAllowed`
No extra code needed. To customize the scheme name:
```java
OidcConfig.builder(...).schemeName("keycloak").build()
```
If `flash-ext-openapi` is absent the integration is silently skipped.
@@ -1,40 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a handler as requiring a valid JWT. Any bearer token that passes
* signature + expiry + issuer validation is accepted — no role check is performed.
*
* <p>For role-based access use {@link RolesAllowed} instead (it implies authentication).
*
* <p>Set {@code optional = true} on public routes that personalise their response when
* the user happens to be logged in but should remain accessible to guests. The middleware
* will populate {@link ClaimsHolder} if credentials are present and silently skip it
* otherwise — the request is never rejected.
*
* <pre>{@code
* // Hard auth — redirects / 401 when unauthenticated:
* @Route(method = HttpMethod.GET, path = "/api/profile")
* @Authenticated
* public class GetProfile extends JacksonHandler { ... }
*
* // Soft auth — guest-friendly, ClaimsHolder populated only when logged in:
* @Route(method = HttpMethod.GET, path = "/")
* @Authenticated(optional = true)
* public class HomePage extends HtmlHandler { ... }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Authenticated {
/**
* When {@code true} the middleware never rejects unauthenticated requests — it only
* populates {@link ClaimsHolder} when valid credentials are present.
* Defaults to {@code false} (hard authentication required).
*/
boolean optional() default false;
}
@@ -1,71 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.Map;
/**
* Thread-local store for JWT claims, populated by the OIDC middleware before
* the handler runs and cleared in the {@code finally} block afterward.
*
* <p>Safe with virtual threads: each request gets its own virtual thread, so
* {@link ThreadLocal} values are naturally isolated per request.
*
* <pre>{@code
* // Inside any handler protected by @Authenticated or @RolesAllowed:
*
* // Preferred — typed wrapper:
* OidcUser user = ClaimsHolder.user();
* String email = user.email();
* List<String> roles = user.roles("realm_access.roles");
*
* // Raw escape hatch:
* Map<String, Object> all = ClaimsHolder.get();
* }</pre>
*/
public final class ClaimsHolder {
private static final ThreadLocal<Map<String, Object>> HOLDER = new ThreadLocal<>();
private ClaimsHolder() {}
/** Called by the OIDC middleware after successful token validation. */
static void set(Map<String, Object> claims) {
HOLDER.set(claims);
}
/** Called by the OIDC middleware in the {@code finally} block. */
static void clear() {
HOLDER.remove();
}
/**
* Returns a type-safe {@link OidcUser} view of the current request's claims,
* or {@code null} if the route is not protected by OIDC middleware.
*
* <p>This is the preferred entry point for both lambda and class-based handlers.
*/
public static OidcUser user() {
Map<String, Object> claims = HOLDER.get();
return claims != null ? new OidcUser(claims) : null;
}
/**
* Returns the raw claims map for the current request, or {@code null} if
* the route is not protected by OIDC middleware.
*
* @see #user() for the preferred type-safe accessor
*/
public static Map<String, Object> get() {
return HOLDER.get();
}
/**
* Returns the value of a single claim as a String, or {@code null} if
* the claim is absent or the request is not authenticated.
*/
public static String claim(String key) {
Map<String, Object> claims = HOLDER.get();
if (claims == null) return null;
Object v = claims.get(key);
return v != null ? v.toString() : null;
}
}
@@ -1,18 +0,0 @@
package dev.relism.flash.ext.oidc;
/**
* OAuth2 client authentication method for the token endpoint (RFC 6749 §2.3).
*
* <ul>
* <li>{@link #POST} — credentials sent as {@code client_id} / {@code client_secret}
* form fields (default; most providers).</li>
* <li>{@link #BASIC} — credentials sent as an {@code Authorization: Basic} header;
* body contains only grant-specific parameters.</li>
* </ul>
*/
public enum ClientAuthMethod {
/** {@code client_secret_post} — credentials in the request body. */
POST,
/** {@code client_secret_basic} — credentials in the {@code Authorization} header. */
BASIC
}
@@ -1,50 +0,0 @@
package dev.relism.flash.ext.oidc;
import net.minidev.json.JSONValue;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
/**
* Fetches and parses the OIDC provider discovery document at
* {@code {issuer}/.well-known/openid-configuration}.
*/
final class DiscoveryClient {
private DiscoveryClient() {}
static OidcProviderMetadata fetch(String issuer, HttpClient http) throws Exception {
String url = issuer.endsWith("/")
? issuer + ".well-known/openid-configuration"
: issuer + "/.well-known/openid-configuration";
HttpResponse<String> resp = http.send(
HttpRequest.newBuilder().uri(URI.create(url)).GET().build(),
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200)
throw new IllegalStateException(
"OIDC discovery failed [" + resp.statusCode() + "]: " + url);
@SuppressWarnings("unchecked")
Map<String, Object> doc = (Map<String, Object>) JSONValue.parse(resp.body());
return new OidcProviderMetadata(
require(doc, "authorization_endpoint"),
require(doc, "token_endpoint"),
(String) doc.get("userinfo_endpoint"), // optional
require(doc, "jwks_uri"),
(String) doc.get("end_session_endpoint") // optional
);
}
private static String require(Map<String, Object> doc, String key) {
Object v = doc.get(key);
if (v == null) throw new IllegalStateException(
"Discovery doc missing required field: " + key);
return v.toString();
}
}
@@ -1,20 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe in-memory {@link OidcSessionStore}.
*
* <p>Sessions are lost on restart and not shared across instances. For
* production deployments with multiple nodes or restart-persistence requirements,
* supply a custom implementation via {@link OidcConfig.Builder#sessionStore}.
*/
public final class InMemoryOidcSessionStore implements OidcSessionStore {
private final ConcurrentHashMap<String, OidcSession> store = new ConcurrentHashMap<>();
@Override public void save(OidcSession s) { store.put(s.id(), s); }
@Override public Optional<OidcSession> find(String id) { return Optional.ofNullable(store.get(id)); }
@Override public void delete(String id) { store.remove(id); }
}
@@ -1,38 +0,0 @@
package dev.relism.flash.ext.oidc;
import net.minidev.json.JSONValue;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
/**
* Low-level JWT payload extraction — no signature or expiry validation.
*
* <p>Use only for tokens received directly from the provider over a trusted TLS
* connection (e.g. {@code id_token} from the token endpoint). Bearer tokens on
* incoming requests must go through {@link JwtValidator#validate(String)} instead.
*/
final class JwtUtils {
private JwtUtils() {}
/**
* Base64URL-decodes the JWT payload and returns the claims as a map.
* Signature, expiry, and issuer are NOT checked.
*/
@SuppressWarnings("unchecked")
static Map<String, Object> parseClaims(String jwt) {
String[] parts = jwt.split("\\.");
if (parts.length < 2) throw new IllegalArgumentException("Malformed JWT: " + jwt);
// Pad to a multiple of 4 for the standard decoder
String padded = parts[1];
switch (padded.length() % 4) {
case 2 -> padded += "==";
case 3 -> padded += "=";
}
byte[] payload = Base64.getUrlDecoder().decode(padded);
return (Map<String, Object>) JSONValue.parse(
new String(payload, StandardCharsets.UTF_8));
}
}
@@ -1,184 +0,0 @@
package dev.relism.flash.ext.oidc;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSKeySelector;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.Resource;
import com.nimbusds.jose.util.ResourceRetriever;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import dev.relism.flash.exceptions.HttpException;
import java.io.IOException;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
import java.util.Set;
/**
* Validates JWTs against a remote JWKS endpoint using Nimbus JOSE+JWT.
*
* <p>Two validation modes:
* <ul>
* <li>{@link #validate(String)} — access token bearer validation per request (hot path).
* Checks signature, {@code iss}, {@code exp}, {@code iat}, {@code sub}.
* Throws {@link HttpException} 401 so the middleware can short-circuit.</li>
* <li>{@link #validateIdToken(String, String)} — ID token validation at callback time.
* Checks signature, {@code iss}, {@code aud} == clientId, {@code exp}, {@code iat},
* {@code sub}, and {@code nonce} (if provided).
* Throws {@link OidcValidationException} (not 401 — it is a provider/protocol error).</li>
* </ul>
*
* <p>JWKS handling: the shared {@link JWKSource} uses caching + rate-limiting + automatic
* retry-on-key-miss (key rotation). Both processors share the same source — one JWKS
* fetch serves both token types.
*/
public class JwtValidator {
private final JWKSource<SecurityContext> jwkSource;
private final ConfigurableJWTProcessor<SecurityContext> accessTokenProcessor;
private final ConfigurableJWTProcessor<SecurityContext> idTokenProcessor;
private final String algorithm;
/**
* @param jwksUri JWKS endpoint URI
* @param issuer Expected {@code iss} claim
* @param clientId OAuth2 client ID — used as expected {@code aud} in ID tokens
* @param algorithm JWS algorithm (e.g. {@code "RS256"})
* @param http Shared {@link HttpClient} used for all JWKS fetches — already configured
* with the correct TLS policy (trust-all or default trust store).
*/
public JwtValidator(String jwksUri, String issuer, String clientId,
String algorithm, HttpClient http) {
try {
// Use the caller-supplied HttpClient for JWKS retrieval so that TLS policy
// (insecureTls / custom trust store) is applied consistently everywhere.
this.jwkSource = JWKSourceBuilder
.create(new URL(jwksUri), httpRetriever(http))
.cache(true)
.rateLimited(true)
.retrying(true)
.build();
} catch (Exception e) {
throw new IllegalStateException("Failed to init JWKS source: " + jwksUri, e);
}
this.algorithm = algorithm;
this.accessTokenProcessor = buildAccessTokenProcessor(jwkSource, issuer, algorithm);
this.idTokenProcessor = buildIdTokenProcessor(jwkSource, issuer, clientId, algorithm);
}
// -- Public API -----------------------------------------------------------
/**
* Validates a JWT access token (bearer on incoming request).
* Returns claims on success; throws {@link HttpException} 401 on any failure.
*/
public Map<String, Object> validate(String token) {
if (!isJwt(token)) throw HttpException.unauthorized(); // opaque token — can't validate
try {
return accessTokenProcessor.process(token, null).getClaims();
} catch (Exception e) {
throw HttpException.unauthorized();
}
}
/**
* Validates an ID token received directly from the token endpoint.
*
* <p>Checks: signature (JWKS), {@code iss}, {@code aud} == clientId,
* {@code exp}, {@code iat}, {@code sub}, and {@code nonce} if provided.
*
* @param idToken Raw ID token string
* @param nonce Nonce sent in the authorization request; {@code null} to skip check
* @throws OidcValidationException on any validation failure
*/
public Map<String, Object> validateIdToken(String idToken, String nonce) {
try {
Map<String, Object> claims = idTokenProcessor.process(idToken, null).getClaims();
if (nonce != null && !nonce.equals(claims.get("nonce")))
throw new OidcValidationException("ID token nonce mismatch", null);
return claims;
} catch (OidcValidationException e) {
throw e;
} catch (Exception e) {
throw new OidcValidationException("ID token validation failed: " + e.getMessage(), e);
}
}
/**
* Returns {@code true} if {@code token} is a signed JWT (three dot-separated Base64URL parts).
* Used to detect opaque access tokens before attempting JWKS validation.
*/
public static boolean isJwt(String token) {
if (token == null || token.isBlank()) return false;
int dots = 0;
for (int i = 0; i < token.length(); i++) if (token.charAt(i) == '.') dots++;
return dots == 2;
}
// -- Processors -----------------------------------------------------------
private static ConfigurableJWTProcessor<SecurityContext> buildAccessTokenProcessor(
JWKSource<SecurityContext> src, String issuer, String algorithm) {
ConfigurableJWTProcessor<SecurityContext> p = new DefaultJWTProcessor<>();
p.setJWSKeySelector(keySelector(src, algorithm));
// iss required; aud not enforced on ATs (varies by provider)
if (issuer != null && !issuer.isBlank()) {
p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
new JWTClaimsSet.Builder().issuer(issuer).build(),
Set.of("sub", "iat", "exp")));
}
return p;
}
private static ConfigurableJWTProcessor<SecurityContext> buildIdTokenProcessor(
JWKSource<SecurityContext> src, String issuer, String clientId, String algorithm) {
ConfigurableJWTProcessor<SecurityContext> p = new DefaultJWTProcessor<>();
p.setJWSKeySelector(keySelector(src, algorithm));
// iss + aud = clientId strictly required (OIDC Core §3.1.3.7)
JWTClaimsSet.Builder required = new JWTClaimsSet.Builder();
if (issuer != null) required.issuer(issuer);
if (clientId != null) required.audience(clientId);
p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
required.build(), Set.of("sub", "iat", "exp")));
return p;
}
private static JWSKeySelector<SecurityContext> keySelector(
JWKSource<SecurityContext> src, String algorithm) {
return new JWSVerificationKeySelector<>(JWSAlgorithm.parse(algorithm), src);
}
/**
* Wraps a {@link HttpClient} as a Nimbus {@link ResourceRetriever}.
* The client already carries the correct TLS policy (trust-all or default),
* so JWKS fetches honour the same SSL configuration as discovery and token requests.
*/
private static ResourceRetriever httpRetriever(HttpClient http) {
return url -> {
try {
HttpResponse<String> resp = http.send(
HttpRequest.newBuilder().uri(url.toURI()).GET().build(),
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200)
throw new IOException("JWKS fetch failed [" + resp.statusCode() + "]: " + url);
String contentType = resp.headers()
.firstValue("Content-Type").orElse("application/json");
return new Resource(resp.body(), contentType);
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("JWKS retrieval error: " + e.getMessage(), e);
}
};
}
}
@@ -1,98 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.LinkedHashSet;
import java.util.List;
/**
* Compiled authorization policy derived from handler annotations at mount time.
* Immutable and allocation-free on the request hot path.
*/
final class OidcAuthPolicy {
private static final String[] EMPTY = new String[0];
private static final OidcAuthPolicy AUTH_REQUIRED = new OidcAuthPolicy(
false, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
private static final OidcAuthPolicy AUTH_OPTIONAL = new OidcAuthPolicy(
true, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
private final boolean optionalAuth;
private final String[] requiredRoles;
private final String[] requiredScopes;
private final ScopesAllowed.Match scopeMatch;
private OidcAuthPolicy(boolean optionalAuth,
String[] requiredRoles,
String[] requiredScopes,
ScopesAllowed.Match scopeMatch) {
this.optionalAuth = optionalAuth;
this.requiredRoles = requiredRoles;
this.requiredScopes = requiredScopes;
this.scopeMatch = scopeMatch;
}
static OidcAuthPolicy authenticated() { return AUTH_REQUIRED; }
static OidcAuthPolicy optional() { return AUTH_OPTIONAL; }
static OidcAuthPolicy rolesAny(String... roles) {
return new OidcAuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL);
}
static OidcAuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) {
return new OidcAuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match);
}
static OidcAuthPolicy compileFromAnnotations(Class<?> handlerClass) {
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
if (auth == null && roles == null && scopes == null) return null;
boolean optionalAuth = auth != null && auth.optional();
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : EMPTY;
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : EMPTY;
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
if (optionalAuth && (requiredRoles.length > 0 || requiredScopes.length > 0)) {
throw new IllegalStateException("@Authenticated(optional = true) cannot be combined with @RolesAllowed/@ScopesAllowed on "
+ handlerClass.getName());
}
return new OidcAuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
}
static List<String> openApiScopesFor(Class<?> handlerClass) {
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
if (auth == null && roles == null && scopes == null) return null;
if (scopes == null) return List.of();
return List.of(normalizeRequired("ScopesAllowed", scopes.value()));
}
boolean optionalAuth() { return optionalAuth; }
String[] requiredRoles() { return requiredRoles; }
String[] requiredScopes() { return requiredScopes; }
ScopesAllowed.Match scopeMatch() { return scopeMatch; }
private static String[] normalizeRequired(String annotation, String[] values) {
if (values == null || values.length == 0)
throw new IllegalStateException("@" + annotation + " requires at least one value");
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
for (String raw : values) {
if (raw == null) continue;
String trimmed = raw.trim();
if (!trimmed.isEmpty()) normalized.add(trimmed);
}
if (normalized.isEmpty())
throw new IllegalStateException("@" + annotation + " requires at least one non-empty value");
return normalized.toArray(String[]::new);
}
}
@@ -1,261 +0,0 @@
package dev.relism.flash.ext.oidc;
/**
* Full OIDC client configuration. Build via
* {@link #builder(String, String, String, String)} or {@link #fromEnv()}.
*
* <p>Required fields: {@code issuer}, {@code clientId}, {@code clientSecret},
* {@code redirectUri}. Everything else has a sensible default.
*
* <p>If {@code redirectUri} starts with {@code /} it is treated as server-relative:
* the absolute URL is resolved at request time using {@link #selfScheme()} and the
* incoming {@code Host} header. Use {@link Builder#https()} when behind TLS.
*
* <pre>{@code
* // Keycloak
* OidcConfig.builder(
* "https://keycloak.example.com/realms/myrealm",
* "my-app", "secret", "/auth/callback")
* .rolesClaimPath("realm_access.roles") // Keycloak default
* .scopeClaimPaths("scope,scp") // default; supports many IdPs
* .build();
*
* // Authelia
* OidcConfig.builder(
* "https://auth.example.com",
* "my-app", "secret", "/auth/callback")
* .rolesClaimPath("groups")
* .scopeClaimPaths("scope,scp")
* .build();
*
* // Two tenants on one server
* OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", ..., "/tenantA/auth/callback")
* .routePrefix("/tenantA/auth").build();
* OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", ..., "/tenantB/auth/callback")
* .routePrefix("/tenantB/auth").build();
* app.install(new OidcExtension(tenantA))
* .install(new OidcExtension(tenantB));
* }</pre>
*/
public final class OidcConfig {
private final String issuer;
private final String clientId;
private final String clientSecret;
private final String redirectUri;
private final String scopes;
private final String routePrefix;
private final String selfScheme;
private final String rolesClaimPath;
private final String scopeClaimPaths;
private final String algorithm;
private final String postLogoutRedirectUri;
private final OidcSessionStore sessionStore;
private final boolean insecureTls;
private final ClientAuthMethod clientAuthMethod;
private final String schemeName;
private OidcConfig(Builder b) {
this.issuer = require(b.issuer, "issuer");
this.clientId = require(b.clientId, "clientId");
this.clientSecret = require(b.clientSecret, "clientSecret");
this.redirectUri = require(b.redirectUri, "redirectUri");
this.scopes = b.scopes;
this.routePrefix = b.routePrefix;
this.selfScheme = b.selfScheme;
this.rolesClaimPath = b.rolesClaimPath;
this.scopeClaimPaths = b.scopeClaimPaths;
this.algorithm = b.algorithm;
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
this.sessionStore = b.sessionStore != null ? b.sessionStore
: new InMemoryOidcSessionStore();
this.insecureTls = b.insecureTls;
this.clientAuthMethod = b.clientAuthMethod;
this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer);
}
// -- Getters --------------------------------------------------------------
public String issuer() { return issuer; }
public String clientId() { return clientId; }
public String clientSecret() { return clientSecret; }
public String redirectUri() { return redirectUri; }
public String scopes() { return scopes; }
public String routePrefix() { return routePrefix; }
public String selfScheme() { return selfScheme; }
public String rolesClaimPath() { return rolesClaimPath; }
/** Comma-separated claim paths used to read OAuth2 scopes (default: {@code "scope,scp"}). */
public String scopeClaimPaths() { return scopeClaimPaths; }
public String algorithm() { return algorithm; }
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
public OidcSessionStore sessionStore() { return sessionStore; }
/** If {@code true}, TLS certificate validation is skipped. <b>Never use in production.</b> */
public boolean insecureTls() { return insecureTls; }
public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; }
/** OpenAPI security scheme name (derived from issuer if not set explicitly). */
public String schemeName() { return schemeName; }
// -- Factory --------------------------------------------------------------
/**
* Reads configuration from environment variables:
* <pre>
* OIDC_ISSUER required
* OIDC_CLIENT_ID required
* OIDC_CLIENT_SECRET required
* OIDC_REDIRECT_URI required (e.g. /auth/callback)
* OIDC_SCOPES default: openid profile email
* OIDC_ROUTE_PREFIX default: /auth
* OIDC_SELF_SCHEME default: http
* OIDC_ROLES_CLAIM default: realm_access.roles
* OIDC_SCOPE_CLAIMS default: scope,scp
* OIDC_ALGORITHM default: RS256
* OIDC_POST_LOGOUT_REDIRECT default: /
* </pre>
*/
public static OidcConfig fromEnv() {
return builder(env("OIDC_ISSUER"), env("OIDC_CLIENT_ID"),
env("OIDC_CLIENT_SECRET"), env("OIDC_REDIRECT_URI"))
.scopes (envOr("OIDC_SCOPES", "openid profile email"))
.routePrefix (envOr("OIDC_ROUTE_PREFIX", "/auth"))
.selfScheme (envOr("OIDC_SELF_SCHEME", "http"))
.rolesClaimPath (envOr("OIDC_ROLES_CLAIM", "realm_access.roles"))
.scopeClaimPaths (envOr("OIDC_SCOPE_CLAIMS", "scope,scp"))
.algorithm (envOr("OIDC_ALGORITHM", "RS256"))
.postLogoutRedirectUri(envOr("OIDC_POST_LOGOUT_REDIRECT", "/"))
.clientAuthMethod(ClientAuthMethod.valueOf(
envOr("OIDC_CLIENT_AUTH_METHOD", "POST").toUpperCase()))
.build();
}
public static Builder builder(String issuer, String clientId,
String clientSecret, String redirectUri) {
return new Builder(issuer, clientId, clientSecret, redirectUri);
}
/**
* Convenience factory for Keycloak: constructs the issuer as
* {@code {serverUrl}/realms/{realm}} automatically.
*
* <pre>{@code
* OidcConfig.keycloak(
* "https://keycloak.example.com", "flashboard",
* "my-app", "secret", "/auth/callback")
* .https()
* .build();
* }</pre>
*/
public static Builder keycloak(String serverUrl, String realm,
String clientId, String clientSecret,
String redirectUri) {
String base = serverUrl.endsWith("/") ? serverUrl.substring(0, serverUrl.length() - 1) : serverUrl;
String issuer = base + "/realms/" + realm;
return new Builder(issuer, clientId, clientSecret, redirectUri)
.rolesClaimPath("realm_access.roles"); // Keycloak default
}
// -- Helpers --------------------------------------------------------------
private static String require(String v, String name) {
if (v == null || v.isBlank())
throw new IllegalArgumentException("OidcConfig: " + name + " is required");
return v;
}
private static String env(String key) {
String v = System.getenv(key);
if (v == null || v.isBlank())
throw new IllegalArgumentException("Missing required env var: " + key);
return v;
}
private static String envOr(String key, String def) {
String v = System.getenv(key);
return (v != null && !v.isBlank()) ? v : def;
}
// -- Builder --------------------------------------------------------------
public static final class Builder {
private final String issuer;
private final String clientId;
private final String clientSecret;
private final String redirectUri;
private String scopes = "openid profile email";
private String routePrefix = "/auth";
private String selfScheme = "http";
private String rolesClaimPath = "realm_access.roles";
private String scopeClaimPaths = "scope,scp";
private String algorithm = "RS256";
private String postLogoutRedirectUri = "/";
private OidcSessionStore sessionStore;
private boolean insecureTls = false;
private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
private String schemeName = null;
private Builder(String issuer, String clientId, String clientSecret, String redirectUri) {
this.issuer = issuer;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.redirectUri = redirectUri;
}
/** Override requested scopes (default: {@code openid profile email}). */
public Builder scopes(String scopes) { this.scopes = scopes; return this; }
/** Route prefix for login/callback/logout (default: {@code /auth}). */
public Builder routePrefix(String prefix) { this.routePrefix = prefix; return this; }
/** Scheme used when resolving self-relative redirect URIs (default: {@code http}). */
public Builder selfScheme(String scheme) { this.selfScheme = scheme; return this; }
/** Shorthand for {@code selfScheme("https")}. */
public Builder https() { return selfScheme("https"); }
/** Dot-separated path to the roles array in JWT claims (default: {@code realm_access.roles}). */
public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
/** Comma-separated claim paths used to resolve OAuth2 scopes (default: {@code scope,scp}). */
public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; }
/** JWS algorithm (default: {@code RS256}). */
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
/** Where to redirect after logout (default: {@code /}). */
public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
/** Custom session store (default: {@link InMemoryOidcSessionStore}). */
public Builder sessionStore(OidcSessionStore store) { this.sessionStore = store; return this; }
/**
* Disables TLS certificate verification for all HTTP calls made by this extension.
* <b>Only use in development with self-signed certificates — never in production.</b>
*/
public Builder insecureTls() { this.insecureTls = true; return this; }
/** Token endpoint client authentication method (default: {@link ClientAuthMethod#POST}). */
public Builder clientAuthMethod(ClientAuthMethod method) { this.clientAuthMethod = method; return this; }
/** Override the OpenAPI security scheme name (default: derived from the issuer URI). */
public Builder schemeName(String name) { this.schemeName = name; return this; }
public OidcConfig build() { return new OidcConfig(this); }
}
/**
* Derives a short, human-readable scheme name from the issuer URI.
* Takes the last non-empty path segment; falls back to the host.
*
* <p>Examples:
* <ul>
* <li>{@code https://keycloak.dev.home/realms/flashboard} → {@code "flashboard"}</li>
* <li>{@code https://auth.example.com} → {@code "auth.example.com"}</li>
* </ul>
*/
private static String deriveScheme(String issuer) {
try {
java.net.URI uri = new java.net.URI(issuer);
String path = uri.getPath();
if (path != null && !path.isEmpty()) {
String[] parts = path.split("/");
for (int i = parts.length - 1; i >= 0; i--) {
if (!parts[i].isEmpty()) return parts[i];
}
}
return uri.getHost();
} catch (Exception e) {
return "oidc";
}
}
}
@@ -1,337 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.ext.openapi.OpenApiContributor;
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.models.Request;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.*;
/**
* Full OIDC Authorization Code + PKCE flow for Flash.
*
* <p>At {@link #provide}, the extension:
* <ol>
* <li>Fetches the provider discovery document — fail-fast at startup.</li>
* <li>Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.</li>
* <li>Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
* and {@link ScopesAllowed}.</li>
* </ol>
*
* <p>At {@link #routes}, three routes are registered:
* <ul>
* <li>{@code GET {prefix}/login} — builds the authorization URL and redirects.</li>
* <li>{@code GET {prefix}/callback} — exchanges the code, creates a session, redirects.</li>
* <li>{@code POST {prefix}/logout} — invalidates the session, redirects to provider
* end-session endpoint (if available) or to {@link OidcConfig#postLogoutRedirectUri()}.</li>
* </ul>
*
* <pre>{@code
* // Keycloak
* app.install(new OidcExtension(
* OidcConfig.builder(
* "https://keycloak.example.com/realms/myrealm",
* "my-app", "secret", "/auth/callback")
* .rolesClaimPath("realm_access.roles")
* .build()));
*
* // Two providers / tenants on one server
* app.install(new OidcExtension(tenantAConfig))
* .install(new OidcExtension(tenantBConfig));
* }</pre>
*/
public class OidcExtension implements FlashExtension {
private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.oidc.policy");
private final OidcConfig config;
// Initialized in provide(), used in routes() — private to this extension instance.
private OidcProviderMetadata meta;
private OidcStateStore stateStore;
private TokenClient tokenClient;
private JwtValidator validator;
private OidcMiddleware oidcMw;
public OidcExtension(OidcConfig config) {
this.config = config;
}
// ── Phase 1: services ─────────────────────────────────────────────────────
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
HttpClient http = buildHttpClient(config);
// Discover provider endpoints (blocking; fail fast at startup).
try {
meta = DiscoveryClient.fetch(config.issuer(), http);
} catch (Exception e) {
throw new IllegalStateException("OIDC discovery failed for issuer: " + config.issuer(), e);
}
validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http);
stateStore = new OidcStateStore();
tokenClient = new TokenClient(http, config);
oidcMw = new OidcMiddleware(validator, config, meta, tokenClient);
ctx.provide(OidcMiddleware.class, oidcMw);
ctx.provide(JwtValidator.class, validator);
ctx.addAnnotationProcessor(handlerClass -> {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
return policy != null ? List.of(MiddlewareNode.of(POLICY, oidcMw.policyMiddleware(policy))) : List.of();
});
ctx.onReady(() -> registerRoutes(app, ctx));
}
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
String prefix = config.routePrefix();
// ── GET {prefix}/login ────────────────────────────────────────────────
// Builds the provider authorization URL with PKCE + state and redirects.
// Optional query param: ?redirect={relative-url} (default: /)
app.get(prefix + "/login", (req, res) -> {
String verifier = PkceUtils.generateVerifier();
String challenge = PkceUtils.computeChallenge(verifier);
String state = UUID.randomUUID().toString();
String nonce = UUID.randomUUID().toString();
String redirect = req.query("redirect");
if (redirect == null || !redirect.startsWith("/")) redirect = "/";
stateStore.put(state, redirect, verifier, nonce);
String authUrl = meta.authorizationEndpoint()
+ "?response_type=code"
+ "&client_id=" + enc(config.clientId())
+ "&redirect_uri=" + enc(absoluteRedirectUri(req))
+ "&scope=" + enc(config.scopes())
+ "&state=" + state
+ "&nonce=" + enc(nonce)
+ "&code_challenge=" + challenge
+ "&code_challenge_method=S256";
res.redirect(authUrl);
return null;
});
// ── GET {prefix}/callback ─────────────────────────────────────────────
// Validates state, exchanges code for tokens, creates session, redirects.
app.get(prefix + "/callback", (req, res) -> {
String error = req.query("error");
if (error != null) {
res.status(400);
return "Authentication error: " + error
+ (req.query("error_description") != null
? "" + req.query("error_description") : "");
}
String code = req.query("code");
String state = req.query("state");
OidcStateStore.Entry entry = stateStore.consumeAndRemove(state).orElse(null);
if (entry == null) {
res.status(400);
return "Invalid or expired state parameter";
}
OidcTokenResponse tokens = tokenClient.exchangeCode(
meta.tokenEndpoint(), code, absoluteRedirectUri(req), entry.codeVerifier());
// Validate ID token: signature + iss + aud + exp + iat + sub + nonce (OIDC Core §3.1.3.7)
if (tokens.idToken() != null) {
try {
validator.validateIdToken(tokens.idToken(), entry.nonce());
} catch (OidcValidationException e) {
res.status(400);
return "ID token validation failed: " + e.getMessage();
}
}
Map<String, Object> claims = mergeClaims(tokens);
OidcSession session = new OidcSession(
UUID.randomUUID().toString(),
tokens.accessToken(), tokens.idToken(), tokens.refreshToken(),
Instant.now().plusSeconds(tokens.expiresIn()), claims);
config.sessionStore().save(session);
res.header("Set-Cookie", sessionCookie(session.id()))
.redirect(entry.originalUrl());
return null;
});
// ── POST {prefix}/logout ──────────────────────────────────────────────
// Invalidates the local session and redirects to end_session_endpoint.
app.post(prefix + "/logout", (req, res) -> {
String sessionId = OidcMiddleware.cookieValue(req, "oidc_session");
String idTokenHint = null;
if (sessionId != null) {
OidcSession session = config.sessionStore().find(sessionId).orElse(null);
if (session != null) idTokenHint = session.idToken();
config.sessionStore().delete(sessionId);
}
String clearCookie = "oidc_session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax";
String location;
if (meta.endSessionEndpoint() != null) {
String postLogout = absoluteSelf(req, config.postLogoutRedirectUri());
StringBuilder url = new StringBuilder(meta.endSessionEndpoint())
.append("?post_logout_redirect_uri=").append(enc(postLogout));
if (idTokenHint != null)
url.append("&id_token_hint=").append(enc(idTokenHint));
location = url.toString();
} else {
location = config.postLogoutRedirectUri();
}
res.header("Set-Cookie", clearCookie).redirect(location);
return null;
});
// Register OpenAPI security scheme if flash-ext-openapi is on the classpath.
try {
OpenApiIntegration.register(ctx, config, meta);
} catch (NoClassDefFoundError ignored) {
// flash-ext-openapi not available — OpenAPI integration disabled
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Merges claims from both the access token and the ID token.
* ID token values win on conflict so that verified identity claims are authoritative.
*/
private static Map<String, Object> mergeClaims(OidcTokenResponse tokens) {
Map<String, Object> merged = new HashMap<>();
if (tokens.accessToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
if (tokens.idToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
return Map.copyOf(merged);
}
/**
* Builds an {@link HttpClient}. If {@link OidcConfig#insecureTls()} is set,
* installs a trust-all {@link SSLContext} that accepts any certificate.
* <b>Only safe for development with self-signed certificates.</b>
*/
private static HttpClient buildHttpClient(OidcConfig config) {
if (!config.insecureTls()) return HttpClient.newHttpClient();
try {
TrustManager[] trustAll = { new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
public void checkClientTrusted(X509Certificate[] c, String a) {}
public void checkServerTrusted(X509Certificate[] c, String a) {}
}};
SSLContext sslCtx = SSLContext.getInstance("TLS");
sslCtx.init(null, trustAll, new SecureRandom());
return HttpClient.newBuilder().sslContext(sslCtx).build();
} catch (Exception e) {
throw new IllegalStateException("Failed to create trust-all SSLContext", e);
}
}
private String absoluteRedirectUri(Request req) {
return absoluteSelf(req, config.redirectUri());
}
private String absoluteSelf(Request req, String uri) {
if (!uri.startsWith("/")) return uri;
return OidcMiddleware.selfOrigin(req, config.selfScheme()) + uri;
}
private static String enc(String v) {
return URLEncoder.encode(v, StandardCharsets.UTF_8);
}
private static String sessionCookie(String id) {
return "oidc_session=" + id + "; HttpOnly; Path=/; SameSite=Lax";
}
/**
* Loaded lazily so that {@code flash-ext-openapi} classes are only resolved at
* runtime when {@link OpenApiContributorRegistry} is actually on the classpath.
*/
private static final class OpenApiIntegration {
static void register(FlashContext ctx,
OidcConfig config, OidcProviderMetadata meta) {
ctx.find(OpenApiContributorRegistry.class)
.ifPresent(registry -> registry.add(new OpenApiContributor() {
@Override
public Map<String, Object> componentContributions() {
Map<String, String> scopesMap = new LinkedHashMap<>();
for (String s : config.scopes().split("\\s+")) {
if (!s.isBlank()) scopesMap.put(s, s);
}
Map<String, Object> flow = new LinkedHashMap<>();
flow.put("authorizationUrl", meta.authorizationEndpoint());
flow.put("tokenUrl", meta.tokenEndpoint());
flow.put("scopes", scopesMap);
Map<String, Object> scheme = new LinkedHashMap<>();
scheme.put("type", "oauth2");
scheme.put("flows", Map.of("authorizationCode", flow));
Map<String, Object> securitySchemes = new LinkedHashMap<>();
securitySchemes.put(config.schemeName(), scheme);
return Map.of("securitySchemes", securitySchemes);
}
@Override
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
OpenApiOperationContribution.Builder out =
OpenApiOperationContribution.builder();
List<String> operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
if (operationScopes != null) {
out.security(config.schemeName(), operationScopes);
}
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
if (policy == null || policy.optionalAuth()) return out.build();
out.response(401, OpenApiResponseContribution.of("Authentication required"));
String[] roles = policy.requiredRoles();
String[] scopes = policy.requiredScopes();
if (roles.length == 0 && scopes.length == 0) return out.build();
String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles);
String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes);
if (roleMessage != null && scopeMessage != null) {
out.response(403, OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage));
} else
out.response(403, OpenApiResponseContribution.of(Objects.requireNonNullElse(roleMessage, scopeMessage)));
return out.build();
}
}));
}
private static String roleRequiredMessage(String[] roles) {
if (roles.length == 1) return "\"" + roles[0] + "\" role required";
return "Roles \"" + String.join(", ", roles) + "\" are required";
}
private static String scopeRequiredMessage(String[] scopes) {
if (scopes.length == 1) return "\"" + scopes[0] + "\" scope required";
return "Scopes \"" + String.join(", ", scopes) + "\" are required";
}
}
}
@@ -1,550 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.Response;
import dev.relism.flash.models.Request;
import dev.relism.flash.routing.Middleware;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Request-level OIDC middleware. Exposed in the {@link FlashContext}
* for manual use on lambda routes; injected automatically for handlers annotated with
* {@link Authenticated}, {@link RolesAllowed} or {@link ScopesAllowed}.
*
* <p>Resolution order on each request:
* <ol>
* <li>{@code Authorization: Bearer ...} header validated via JWKS ({@link JwtValidator}).</li>
* <li>{@code oidc_session} cookie looked up in {@link OidcSessionStore}; transparently
* refreshed if the access token is expired.</li>
* <li>Browser clients (no {@code Accept: application/json}) redirect to
* {@code {routePrefix}/login?redirect={path}}.</li>
* <li>API clients 401.</li>
* </ol>
*
* <pre>{@code
* // Manual use on a lambda route:
* OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
* app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), oidc.protect());
* app.delete("/admin/users/{id}", handler, oidc.requireRole("admin"));
* }</pre>
*/
public class OidcMiddleware {
private static final String BEARER = "Bearer";
private final JwtValidator validator;
private final OidcConfig config;
private final OidcProviderMetadata meta;
private final TokenClient tokenClient;
private final String[] roleClaimPathParts;
private final String[][] scopeClaimPathParts;
OidcMiddleware(JwtValidator validator, OidcConfig config,
OidcProviderMetadata meta, TokenClient tokenClient) {
this.validator = validator;
this.config = config;
this.meta = meta;
this.tokenClient = tokenClient;
this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath());
this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths());
}
// -- Public API -----------------------------------------------------------
/** The single configured claim path used by every transport for role checks. */
public String rolesClaimPath() { return config.rolesClaimPath(); }
/**
* Validates the bearer token or session cookie. Browser clients are redirected
* to the login page on failure; API clients receive 401.
*/
public Middleware protect() {
return protect(null);
}
/**
* Like {@link #protect()}, but a 401 challenge also carries {@code resource_metadata}
* (RFC 9728 §5.1), resolved against this request's own scheme/host exactly like
* {@link OidcExtension}'s redirect URIs. {@code resourceMetadataPath} is an absolute path
* (e.g. {@code "/.well-known/oauth-protected-resource/mcp"}); pass {@code null} for plain
* challenges. Used by {@code flash-ext-mcp} to make its Protected Resource Metadata
* document discoverable straight from the {@code WWW-Authenticate} header, per the MCP
* Authorization spec.
*/
public Middleware protect(String resourceMetadataPath) {
return next -> (req, res) -> {
Map<String, Object> claims = resolve(req, res, resourceMetadataPath);
if (claims == null) return null; // redirect already written
ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/** OIDC issuer this middleware validates tokens against — the {@code iss} claim it enforces. */
public String issuer() { return config.issuer(); }
/** Scheme used to build this app's own absolute URLs — see {@link OidcConfig#selfScheme()}. */
public String selfScheme() { return config.selfScheme(); }
/**
* Silently populates {@link ClaimsHolder} if a valid bearer token or session cookie
* is present, but never rejects or redirects unauthenticated requests. Use this on
* public routes that want to personalise the response when the user happens to be
* logged in (e.g. showing a username on a landing page).
*
* <pre>{@code
* app.get("/", handler, oidc.optional());
* // Inside handler: ClaimsHolder.user() is non-null iff the user is logged in.
* }</pre>
*/
public Middleware optional() {
return next -> (req, res) -> {
Map<String, Object> claims = resolveQuiet(req);
if (claims != null) ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/**
* Compiled authorization policy path used by annotation-driven mounting.
* The policy is immutable and built once at boot.
*/
public Middleware authorize(OidcAuthPolicy policy) {
if (policy.optionalAuth()) return optional();
return next -> (req, res) -> {
Map<String, Object> claims = resolve(req, res);
if (claims == null) return null;
enforcePolicy(claims, policy, res);
ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/**
* Like {@link #protect()} but also enforces that the caller holds at least one
* of the given roles (OR semantics). Roles are extracted via
* {@link OidcConfig#rolesClaimPath()}.
*/
public Middleware requireRole(String... roles) {
return authorize(OidcAuthPolicy.rolesAny(roles));
}
/**
* Requires all listed scopes to be present in the token.
* Scopes are resolved from configured claim paths (default: {@code scope,scp}).
*/
public Middleware requireScopes(String... scopes) {
return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL));
}
/**
* Requires at least one of the listed scopes to be present in the token.
* Scopes are resolved from configured claim paths (default: {@code scope,scp}).
*/
public Middleware requireAnyScope(String... scopes) {
return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY));
}
// -- Package-private: AnnotationProcessor hooks ---------------------------
Middleware authenticatedMiddleware() { return protect(); }
Middleware optionalMiddleware() { return optional(); }
Middleware rolesMiddleware(String[] required) { return requireRole(required); }
Middleware scopesMiddleware(String[] required, ScopesAllowed.Match match) {
return authorize(OidcAuthPolicy.scopes(required, match));
}
Middleware policyMiddleware(OidcAuthPolicy policy) { return authorize(policy); }
// -- Internals ------------------------------------------------------------
/**
* Like {@link #resolve} but never redirects or throws returns {@code null} silently
* when no valid credentials are present. Used by {@link #optional()}.
*/
private Map<String, Object> resolveQuiet(Request req) {
String bearerToken = extractBearerToken(req.header("Authorization"));
if (bearerToken != null) {
try {
return validator.validate(bearerToken);
} catch (Exception ignored) {
return null;
}
}
String sessionId = cookieValue(req, "oidc_session");
if (sessionId != null) {
Optional<OidcSession> found = config.sessionStore().find(sessionId);
if (found.isPresent()) {
OidcSession session = found.get();
if (!session.isAccessTokenExpired())
return session.claims();
if (session.refreshToken() != null) {
try {
OidcSession refreshed = doRefresh(session);
config.sessionStore().save(refreshed);
return refreshed.claims();
} catch (Exception ignored) { }
}
config.sessionStore().delete(sessionId);
}
}
return null;
}
/**
* Returns claims on success, or {@code null} if a redirect was already written to
* {@code res}. Throws {@link HttpException} 401/403 for API clients.
*/
private Map<String, Object> resolve(Request req, Response res) {
return resolve(req, res, null);
}
private Map<String, Object> resolve(Request req, Response res, String resourceMetadataPath) {
// 1. Bearer token
String bearerToken = extractBearerToken(req.header("Authorization"));
if (bearerToken != null) {
try {
return validator.validate(bearerToken);
} catch (HttpException e) {
res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath));
throw e;
}
}
// 2. Session cookie
String sessionId = cookieValue(req, "oidc_session");
if (sessionId != null) {
Optional<OidcSession> found = config.sessionStore().find(sessionId);
if (found.isPresent()) {
OidcSession session = found.get();
if (!session.isAccessTokenExpired())
return session.claims();
// Access token expired try silent refresh
if (session.refreshToken() != null) {
try {
OidcSession refreshed = doRefresh(session);
config.sessionStore().save(refreshed);
return refreshed.claims();
} catch (Exception ignored) {
// Refresh failed fall through to re-authenticate
}
}
config.sessionStore().delete(sessionId);
}
}
// 3. No valid credentials
String accept = req.header("Accept");
if (accept != null && accept.contains("application/json")) {
res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath));
throw HttpException.unauthorized();
}
// Browser redirect to login, preserving the original URL in state
String loginUrl = config.routePrefix() + "/login?redirect="
+ URLEncoder.encode(req.path(), StandardCharsets.UTF_8);
res.redirect(loginUrl);
return null;
}
private OidcSession doRefresh(OidcSession old) throws Exception {
OidcTokenResponse tokens = tokenClient.refresh(
meta.tokenEndpoint(), old.refreshToken());
Map<String, Object> claims = mergeRefreshedClaims(tokens, old);
return new OidcSession(
old.id(),
tokens.accessToken(),
tokens.idToken() != null ? tokens.idToken() : old.idToken(),
tokens.refreshToken() != null ? tokens.refreshToken() : old.refreshToken(),
Instant.now().plusSeconds(tokens.expiresIn()),
claims
);
}
private void enforcePolicy(Map<String, Object> claims, OidcAuthPolicy policy, Response res) {
checkRoles(claims, policy.requiredRoles());
checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res);
}
private void checkRoles(Map<String, Object> claims, String[] required) {
if (required.length == 0) return;
if (rolesAllowed(claims, required)) return;
throw HttpException.forbidden();
}
private void checkScopes(Map<String, Object> claims, String[] required, ScopesAllowed.Match match,
Response res) {
if (required.length == 0) return;
if (scopesAllowed(claims, required, match)) return;
res.header("WWW-Authenticate", insufficientScopeChallenge(required));
throw HttpException.forbidden();
}
static String extractBearerToken(String authorizationHeader) {
if (authorizationHeader == null) return null;
int len = authorizationHeader.length();
int start = 0;
while (start < len && Character.isWhitespace(authorizationHeader.charAt(start))) start++;
int schemeEnd = start + BEARER.length();
if (schemeEnd > len || !authorizationHeader.regionMatches(true, start, BEARER, 0, BEARER.length())) {
return null;
}
if (schemeEnd == len || !Character.isWhitespace(authorizationHeader.charAt(schemeEnd))) {
return null;
}
int tokenStart = schemeEnd;
while (tokenStart < len && Character.isWhitespace(authorizationHeader.charAt(tokenStart))) tokenStart++;
if (tokenStart >= len) return null;
int tokenEnd = len;
while (tokenEnd > tokenStart && Character.isWhitespace(authorizationHeader.charAt(tokenEnd - 1))) tokenEnd--;
return tokenEnd > tokenStart ? authorizationHeader.substring(tokenStart, tokenEnd) : null;
}
String bearerChallenge() {
return bearerChallenge(null, null);
}
private String bearerChallenge(Request req, String resourceMetadataPath) {
String base = BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
if (resourceMetadataPath == null) return base;
return base + ", resource_metadata=\"" + quoted(absoluteSelf(req, resourceMetadataPath)) + "\"";
}
String invalidTokenChallenge() {
return invalidTokenChallenge(null, null);
}
private String invalidTokenChallenge(Request req, String resourceMetadataPath) {
return bearerChallenge(req, resourceMetadataPath) + ", error=\"invalid_token\"";
}
String insufficientScopeChallenge(String[] requiredScopes) {
return bearerChallenge() + ", error=\"insufficient_scope\", scope=\""
+ quoted(spaceDelimited(requiredScopes)) + "\"";
}
private String absoluteSelf(Request req, String path) {
if (!path.startsWith("/")) return path;
return selfOrigin(req, config.selfScheme()) + path;
}
/**
* {@code scheme://host} clients actually reach this app on the basis for every absolute
* URL it publishes about itself (OAuth2 {@code redirect_uri}, the RFC 9728 resource
* identifier and the {@code resource_metadata} challenge). Behind a reverse proxy the
* request's own {@code Host} is the upstream address the proxy dialled, so
* {@code X-Forwarded-Host}/{@code -Proto} win whenever present: without them the app would
* name an address no client can resolve, and OAuth2 discovery fails with no error anyone
* can trace back to here. Trusted unconditionally a caller able to reach this app without
* passing the proxy can do worse than spoof a self URL.
*/
public static String selfOrigin(Request req, String fallbackScheme) {
String forwardedHost = req.header("X-Forwarded-Host");
if (forwardedHost == null) return fallbackScheme + "://" + req.header("Host");
String forwardedProto = req.header("X-Forwarded-Proto");
return (forwardedProto != null ? forwardedProto : fallbackScheme) + "://" + forwardedHost;
}
private static String spaceDelimited(String[] values) {
if (values == null || values.length == 0) return "";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < values.length; i++) {
if (i > 0) sb.append(' ');
sb.append(values[i]);
}
return sb.toString();
}
private static String quoted(String value) {
StringBuilder out = new StringBuilder(value.length() + 8);
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '"' || c == '\\') out.append('\\');
out.append(c);
}
return out.toString();
}
boolean rolesAllowed(Map<String, Object> claims, String[] required) {
Object actual = valueAtPath(claims, roleClaimPathParts);
if (actual == null) return false;
for (String role : required) {
if (containsToken(actual, role)) return true;
}
return false;
}
boolean scopesAllowed(Map<String, Object> claims, String[] required, ScopesAllowed.Match match) {
if (match == ScopesAllowed.Match.ALL) {
for (String scope : required) {
if (!hasScope(claims, scope)) return false;
}
return true;
}
for (String scope : required) {
if (hasScope(claims, scope)) return true;
}
return false;
}
private boolean hasScope(Map<String, Object> claims, String scope) {
for (String[] pathParts : scopeClaimPathParts) {
Object value = valueAtPath(claims, pathParts);
if (value != null && containsToken(value, scope)) return true;
}
return false;
}
private static Object valueAtPath(Map<String, Object> claims, String[] pathParts) {
Object current = claims;
for (String part : pathParts) {
if (!(current instanceof Map<?, ?> map)) return null;
current = map.get(part);
if (current == null) return null;
}
return current;
}
private static boolean containsToken(Object source, String token) {
if (source instanceof String s) return containsDelimitedToken(s, token);
if (source instanceof List<?> list) {
for (Object item : list) {
if (item == null) continue;
if (tokenEquals(item.toString(), token)) return true;
}
return false;
}
if (source instanceof Object[] arr) {
for (Object item : arr) {
if (item == null) continue;
if (tokenEquals(item.toString(), token)) return true;
}
return false;
}
return tokenEquals(source.toString(), token);
}
private static boolean containsDelimitedToken(String value, String token) {
int len = value.length();
int i = 0;
while (i < len) {
while (i < len && isScopeDelimiter(value.charAt(i))) i++;
int start = i;
while (i < len && !isScopeDelimiter(value.charAt(i))) i++;
int end = i;
if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) {
return true;
}
}
return false;
}
private static boolean tokenEquals(String value, String token) {
int start = 0;
int end = value.length();
while (start < end && Character.isWhitespace(value.charAt(start))) start++;
while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--;
return end - start == token.length() && value.regionMatches(start, token, 0, token.length());
}
private static boolean isScopeDelimiter(char c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
}
private static String[] splitClaimPath(String path) {
if (path == null || path.isBlank()) {
throw new IllegalStateException("OIDC claim path cannot be blank");
}
List<String> parts = new ArrayList<>(4);
int start = 0;
int len = path.length();
for (int i = 0; i <= len; i++) {
if (i == len || path.charAt(i) == '.') {
String p = path.substring(start, i).trim();
if (!p.isEmpty()) parts.add(p);
start = i + 1;
}
}
if (parts.isEmpty()) {
throw new IllegalStateException("OIDC claim path cannot be blank");
}
return parts.toArray(String[]::new);
}
private static String[][] splitClaimPaths(String paths) {
String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths;
List<String[]> out = new ArrayList<>(4);
int start = 0;
int len = source.length();
for (int i = 0; i <= len; i++) {
if (i == len || source.charAt(i) == ',') {
String raw = source.substring(start, i).trim();
if (!raw.isEmpty()) out.add(splitClaimPath(raw));
start = i + 1;
}
}
if (out.isEmpty()) {
return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") };
}
return out.toArray(String[][]::new);
}
private static Map<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) {
Map<String, Object> merged = new HashMap<>();
// Fall back to old claims first, then overlay fresh token claims
merged.putAll(old.claims());
if (tokens.accessToken() != null)
merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
if (tokens.idToken() != null)
merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
return Map.copyOf(merged);
}
// -- Shared cookie utility (also used by OidcExtension) -------------------
static String cookieValue(Request req, String name) {
String header = req.header("Cookie");
if (header == null || header.isBlank()) return null;
int len = header.length();
int start = 0;
while (start < len) {
int semi = header.indexOf(';', start);
int end = semi < 0 ? len : semi;
int eq = header.indexOf('=', start);
if (eq > start && eq < end) {
int ns = start, ne = eq;
while (ns < ne && header.charAt(ns) == ' ') ns++;
while (ne > ns && header.charAt(ne-1) == ' ') ne--;
if (ne - ns == name.length() && header.regionMatches(ns, name, 0, name.length()))
return header.substring(eq + 1, end).strip();
}
start = end + 1;
}
return null;
}
}
@@ -1,15 +0,0 @@
package dev.relism.flash.ext.oidc;
/**
* OIDC provider endpoints discovered from {@code {issuer}/.well-known/openid-configuration}.
*
* <p>{@link #endSessionEndpoint()} may be {@code null} not all providers expose it
* (e.g. some Authelia configurations omit it).
*/
public record OidcProviderMetadata(
String authorizationEndpoint,
String tokenEndpoint,
String userinfoEndpoint,
String jwksUri,
String endSessionEndpoint // nullable
) {}
@@ -1,47 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.time.Instant;
import java.util.Map;
/**
* An authenticated user's OIDC session persisted in {@link OidcSessionStore} and
* looked up via the {@code oidc_session} cookie on every request.
*
* <p>Sessions are immutable; a refreshed access token produces a new instance
* that replaces the old one in the store (same {@link #id()}).
*/
public final class OidcSession {
private final String id;
private final String accessToken;
private final String idToken;
private final String refreshToken; // may be null
private final Instant accessTokenExpiresAt;
private final Map<String, Object> claims; // decoded from id_token
public OidcSession(String id, String accessToken, String idToken,
String refreshToken, Instant accessTokenExpiresAt,
Map<String, Object> claims) {
this.id = id;
this.accessToken = accessToken;
this.idToken = idToken;
this.refreshToken = refreshToken;
this.accessTokenExpiresAt = accessTokenExpiresAt;
this.claims = Map.copyOf(claims);
}
/**
* Returns {@code true} if the access token has expired or will expire within
* the next 30 seconds (eager refresh to avoid mid-request expiry).
*/
public boolean isAccessTokenExpired() {
return Instant.now().isAfter(accessTokenExpiresAt.minusSeconds(30));
}
public String id() { return id; }
public String accessToken() { return accessToken; }
public String idToken() { return idToken; }
public String refreshToken() { return refreshToken; }
public Instant accessTokenExpiresAt() { return accessTokenExpiresAt; }
public Map<String, Object> claims() { return claims; }
}
@@ -1,14 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.Optional;
/**
* Backing store for {@link OidcSession} objects. The default implementation is
* {@link InMemoryOidcSessionStore}; supply a custom one via
* {@link OidcConfig.Builder#sessionStore(OidcSessionStore)} for Redis, JDBC, etc.
*/
public interface OidcSessionStore {
void save(OidcSession session);
Optional<OidcSession> find(String sessionId);
void delete(String sessionId);
}
@@ -1,39 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Short-lived store mapping state nonces (original URL, PKCE verifier).
*
* <p>Entries expire after {@value #TTL_SECONDS} seconds. Cleanup runs on every
* access to prevent unbounded growth without needing a background thread.
*/
final class OidcStateStore {
static final int TTL_SECONDS = 600; // 10 minutes
record Entry(String originalUrl, String codeVerifier, String nonce, Instant expiresAt) {}
private final ConcurrentHashMap<String, Entry> store = new ConcurrentHashMap<>();
void put(String state, String originalUrl, String codeVerifier, String nonce) {
cleanup();
store.put(state, new Entry(originalUrl, codeVerifier, nonce,
Instant.now().plusSeconds(TTL_SECONDS)));
}
/** Atomically retrieves and removes the entry; returns empty if absent or expired. */
Optional<Entry> consumeAndRemove(String nonce) {
cleanup();
Entry e = store.remove(nonce);
if (e == null || Instant.now().isAfter(e.expiresAt())) return Optional.empty();
return Optional.of(e);
}
private void cleanup() {
Instant now = Instant.now();
store.entrySet().removeIf(kv -> now.isAfter(kv.getValue().expiresAt()));
}
}
@@ -1,10 +0,0 @@
package dev.relism.flash.ext.oidc;
/** Parsed response from an OAuth2 token endpoint. Package-private — internal use only. */
record OidcTokenResponse(
String accessToken,
String idToken, // may be null on refresh if provider omits it
String refreshToken, // may be null
int expiresIn,
int refreshExpiresIn
) {}
@@ -1,237 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;
/**
* Type-safe view over the JWT claims stored in {@link ClaimsHolder}.
*
* <p>Obtainable from any protected context via {@link ClaimsHolder#user()}.
* Class-based handlers that extend the {@code SessionHandler} hierarchy already
* have a provisioned DB user in {@code currentUser}; {@code OidcUser} complements
* that by giving access to the raw OIDC claims when needed, and is the primary
* API for lambda routes.
*
* <pre>{@code
* // Lambda route (OidcMiddleware injected):
* app.get("/api/whoami", (req, res) -> {
* OidcUser u = ClaimsHolder.user();
* return Map.of("sub", u.sub(), "email", u.email(), "roles", u.roles("realm_access.roles"), "scopes", u.scopes());
* }, oidcMw.protect());
*
* // Class-based handler (currentUser is the DB entity; oidcUser() for raw claims):
* protected Object handleAuthenticated(Request req, Response res) throws Exception {
* OidcUser u = oidcUser(); // same as ClaimsHolder.user()
* return json(res, currentUser); // DB entity provisioned from OIDC sub
* }
* }</pre>
*/
public final class OidcUser {
private final Map<String, Object> claims;
OidcUser(Map<String, Object> claims) {
this.claims = claims;
}
// Common OIDC standard claims
/** Subject identifier — unique, stable user ID issued by the provider. */
public String sub() { return str("sub"); }
/** User's email address ({@code email} claim). */
public String email() { return str("email"); }
/** Human-readable username ({@code preferred_username} claim). */
public String username() { return str("preferred_username"); }
/** Full display name ({@code name} claim). */
public String name() { return str("name"); }
// Roles
/**
* Extracts the roles list by traversing a dot-separated claim path.
*
* <p>Example paths:
* <ul>
* <li>{@code "realm_access.roles"} Keycloak realm roles</li>
* <li>{@code "resource_access.my-client.roles"} Keycloak client roles</li>
* <li>{@code "groups"} Authelia / generic IdPs</li>
* </ul>
*
* @return list of role strings, or an empty list if the path doesn't exist
*/
@SuppressWarnings("unchecked")
public List<String> roles(String claimPath) {
String[] parts = claimPath.split("\\.");
Object current = claims;
for (String part : parts) {
if (!(current instanceof Map<?, ?> m)) return List.of();
current = m.get(part);
}
if (current instanceof List<?> list)
return list.stream().map(Object::toString).toList();
return List.of();
}
/** Returns {@code true} if the user holds {@code role} at the given claim path. */
public boolean hasRole(String claimPath, String role) {
return roles(claimPath).contains(role);
}
// -- Scopes ---------------------------------------------------------------
/**
* Resolves OAuth2 scopes from standard OIDC/OAuth claims using fallback order:
* {@code scope} then {@code scp}. Supports both space-separated string and list forms.
*/
public List<String> scopes() {
return scopes("scope,scp");
}
/**
* Resolves scopes from comma-separated claim paths (example: {@code "scope,scp,permissions.scopes"}).
*/
public List<String> scopes(String claimPaths) {
List<String> out = new ArrayList<>();
for (String[] path : splitClaimPaths(claimPaths)) {
Object value = valueAtPath(path);
if (value == null) continue;
if (value instanceof String s) {
appendDelimitedTokens(out, s);
continue;
}
if (value instanceof List<?> list) {
for (Object item : list) {
if (item == null) continue;
String token = item.toString().trim();
if (!token.isEmpty()) out.add(token);
}
continue;
}
String token = value.toString().trim();
if (!token.isEmpty()) out.add(token);
}
return out.isEmpty() ? List.of() : List.copyOf(out);
}
/** Returns {@code true} if the user has {@code scope}, searching default claim paths {@code scope,scp}. */
public boolean hasScope(String scope) {
return hasScope("scope,scp", scope);
}
/** Returns {@code true} if the user has {@code scope} in any of {@code claimPaths}. */
public boolean hasScope(String claimPaths, String scope) {
if (scope == null || scope.isBlank()) return false;
String target = scope.trim();
for (String[] path : splitClaimPaths(claimPaths)) {
Object value = valueAtPath(path);
if (value == null) continue;
if (value instanceof String s && containsDelimitedToken(s, target)) return true;
if (value instanceof List<?> list) {
for (Object item : list) {
if (item == null) continue;
if (target.equals(item.toString().trim())) return true;
}
continue;
}
if (target.equals(value.toString().trim())) return true;
}
return false;
}
// Arbitrary claim access
/**
* Returns the value of any claim, cast to {@code T}.
*
* @throws ClassCastException if the stored value is not assignable to {@code type}
*/
public <T> T claim(String key, Class<T> type) {
return type.cast(claims.get(key));
}
/** Returns the raw claim value, or {@code null} if absent. */
public Object claim(String key) { return claims.get(key); }
/** Escape hatch — returns the full unmodified claims map. */
public Map<String, Object> claims() { return claims; }
// Internals
private String str(String key) {
Object v = claims.get(key);
return v != null ? v.toString() : null;
}
private Object valueAtPath(String[] path) {
Object current = claims;
for (String part : path) {
if (!(current instanceof Map<?, ?> m)) return null;
current = m.get(part);
if (current == null) return null;
}
return current;
}
private static String[][] splitClaimPaths(String claimPaths) {
String source = (claimPaths == null || claimPaths.isBlank()) ? "scope,scp" : claimPaths;
List<String[]> out = new ArrayList<>(4);
int start = 0;
int len = source.length();
for (int i = 0; i <= len; i++) {
if (i == len || source.charAt(i) == ',') {
String raw = source.substring(start, i).trim();
if (!raw.isEmpty()) out.add(splitPath(raw));
start = i + 1;
}
}
return out.isEmpty() ? new String[][]{ splitPath("scope"), splitPath("scp") } : out.toArray(String[][]::new);
}
private static String[] splitPath(String path) {
List<String> out = new ArrayList<>(4);
int start = 0;
int len = path.length();
for (int i = 0; i <= len; i++) {
if (i == len || path.charAt(i) == '.') {
String raw = path.substring(start, i).trim();
if (!raw.isEmpty()) out.add(raw);
start = i + 1;
}
}
return out.isEmpty() ? new String[]{ path } : out.toArray(String[]::new);
}
private static void appendDelimitedTokens(List<String> target, String source) {
int len = source.length();
int i = 0;
while (i < len) {
while (i < len && isDelimiter(source.charAt(i))) i++;
int start = i;
while (i < len && !isDelimiter(source.charAt(i))) i++;
if (i > start) target.add(source.substring(start, i));
}
}
private static boolean containsDelimitedToken(String source, String token) {
int len = source.length();
int i = 0;
while (i < len) {
while (i < len && isDelimiter(source.charAt(i))) i++;
int start = i;
while (i < len && !isDelimiter(source.charAt(i))) i++;
int end = i;
if (end > start && end - start == token.length() && source.regionMatches(start, token, 0, token.length())) {
return true;
}
}
return false;
}
private static boolean isDelimiter(char c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
}
}
@@ -1,14 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.exceptions.HttpException;
/**
* Thrown when OIDC token validation fails (signature, claims, nonce, expiry, etc.).
* Distinct from {@link HttpException}: this signals a protocol-level
* failure, not an HTTP response callers decide the appropriate status code.
*/
public final class OidcValidationException extends RuntimeException {
public OidcValidationException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -1,36 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
/**
* PKCE (RFC 7636) utilities: code verifier generation and S256 challenge computation.
* Package-private used exclusively by {@link OidcExtension}.
*/
final class PkceUtils {
private static final SecureRandom RANDOM = new SecureRandom();
private PkceUtils() {}
/**
* Generates a cryptographically random code verifier (43 URL-safe characters,
* per RFC 7636 §4.1 32 bytes encoded as unpadded Base64URL).
*/
static String generateVerifier() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
/**
* Computes the S256 code challenge: {@code BASE64URL(SHA-256(ASCII(verifier)))}.
*/
static String computeChallenge(String verifier) throws Exception {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(verifier.getBytes(StandardCharsets.US_ASCII));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
}
@@ -1,32 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Restricts a handler to callers whose JWT contains at least one of the
* specified roles. Authentication is implicitly required no need to combine
* with {@link Authenticated}.
*
* <p>Roles are read from the claim configured in {@link OidcConfig#rolesClaimPath()}
* (default: {@code "roles"}). Nested paths like {@code "realm_access.roles"} are
* supported with dot notation.
*
* <pre>{@code
* @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
* @RolesAllowed("admin")
* public class DeleteBlog extends JacksonHandler { ... }
*
* // Multiple accepted roles (OR semantics any one role is sufficient):
* @RolesAllowed({"admin", "editor"})
* public class UpdateBlog extends JacksonHandler { ... }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface RolesAllowed {
/** One or more role names. Access is granted if the caller has any of them. */
String[] value();
}
@@ -1,45 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Restricts a handler to callers whose token carries the required OAuth2 scopes.
* Authentication is implicitly required.
*
* <p>Scopes are resolved from the configured claim paths in
* {@link OidcConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support
* both standard formats:
* <ul>
* <li>{@code scope}: space-separated string</li>
* <li>{@code scp}: string list (or string)</li>
* </ul>
*
* <pre>{@code
* @Route(method = HttpMethod.GET, path = "/api/orders")
* @ScopesAllowed("orders:read")
* public class ListOrders extends JacksonHandler { ... }
*
* @Route(method = HttpMethod.POST, path = "/api/orders")
* @ScopesAllowed(value = {"orders:write", "payments:write"}, match = ScopesAllowed.Match.ANY)
* public class CreateOrder extends JacksonHandler { ... }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ScopesAllowed {
/** Required scopes. */
String[] value();
/** Matching mode for {@link #value()}. */
Match match() default Match.ALL;
enum Match {
/** Any one required scope is sufficient. */
ANY,
/** All required scopes must be present. */
ALL
}
}
@@ -1,112 +0,0 @@
package dev.relism.flash.ext.oidc;
import net.minidev.json.JSONValue;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* HTTP client for OAuth2 token endpoint operations (pure HTTP, no SDK).
*
* <p>Supports two client authentication methods (RFC 6749 §2.3):
* <ul>
* <li>{@link ClientAuthMethod#POST} credentials in form body ({@code client_secret_post})</li>
* <li>{@link ClientAuthMethod#BASIC} credentials in {@code Authorization: Basic} header
* ({@code client_secret_basic})</li>
* </ul>
*/
final class TokenClient {
private final HttpClient http;
private final String clientId;
private final String clientSecret;
private final ClientAuthMethod authMethod;
TokenClient(HttpClient http, OidcConfig config) {
this.http = http;
this.clientId = config.clientId();
this.clientSecret = config.clientSecret();
this.authMethod = config.clientAuthMethod();
}
/** Authorization Code + PKCE exchange. */
OidcTokenResponse exchangeCode(String tokenEndpoint,
String code, String redirectUri,
String codeVerifier) throws Exception {
Map<String, String> params = new LinkedHashMap<>();
params.put("grant_type", "authorization_code");
params.put("code", code);
params.put("redirect_uri", redirectUri);
params.put("code_verifier", codeVerifier);
return post(tokenEndpoint, params);
}
/** Refresh token grant. */
OidcTokenResponse refresh(String tokenEndpoint, String refreshToken) throws Exception {
Map<String, String> params = new LinkedHashMap<>();
params.put("grant_type", "refresh_token");
params.put("refresh_token", refreshToken);
return post(tokenEndpoint, params);
}
// -- Internals ------------------------------------------------------------
private OidcTokenResponse post(String url, Map<String, String> params) throws Exception {
HttpRequest.Builder req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/x-www-form-urlencoded");
if (authMethod == ClientAuthMethod.BASIC) {
String creds = Base64.getEncoder().encodeToString(
(clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
req.header("Authorization", "Basic " + creds);
} else {
params.put("client_id", clientId);
params.put("client_secret", clientSecret);
}
HttpResponse<String> resp = http.send(
req.POST(HttpRequest.BodyPublishers.ofString(form(params))).build(),
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() < 200 || resp.statusCode() >= 300)
throw new IllegalStateException(
"Token endpoint [" + resp.statusCode() + "]: " + resp.body());
@SuppressWarnings("unchecked")
Map<String, Object> json = (Map<String, Object>) JSONValue.parse(resp.body());
return new OidcTokenResponse(
(String) json.get("access_token"),
(String) json.get("id_token"),
(String) json.get("refresh_token"),
numInt(json, "expires_in", 300),
numInt(json, "refresh_expires_in", 1800)
);
}
private static String form(Map<String, String> params) {
StringBuilder sb = new StringBuilder();
params.forEach((k, v) -> {
if (!sb.isEmpty()) sb.append('&');
sb.append(enc(k)).append('=').append(enc(v));
});
return sb.toString();
}
private static String enc(String v) {
return URLEncoder.encode(v, StandardCharsets.UTF_8);
}
private static int numInt(Map<String, Object> m, String key, int def) {
Object v = m.get(key);
return v instanceof Number n ? n.intValue() : def;
}
}
@@ -1,94 +0,0 @@
package dev.relism.flash.ext.oidc;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class OidcAuthPolicyTest {
static class PlainHandler {}
@Authenticated
static class AuthenticatedHandler {}
@Authenticated(optional = true)
static class OptionalHandler {}
@RolesAllowed({"admin", " editor ", "admin"})
static class RolesHandler {}
@ScopesAllowed(value = {"orders:write", " payments:write ", "orders:write"}, match = ScopesAllowed.Match.ANY)
static class ScopesHandler {}
@Authenticated
@RolesAllowed("admin")
@ScopesAllowed(value = {"orders:read", "payments:read"}, match = ScopesAllowed.Match.ALL)
static class CombinedHandler {}
@Authenticated(optional = true)
@ScopesAllowed("orders:read")
static class InvalidOptionalHandler {}
@Test
void compileFromAnnotations_noSecurityAnnotations_returnsNull() {
assertNull(OidcAuthPolicy.compileFromAnnotations(PlainHandler.class));
}
@Test
void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(AuthenticatedHandler.class);
assertNotNull(policy);
assertFalse(policy.optionalAuth());
assertEquals(0, policy.requiredRoles().length);
assertEquals(0, policy.requiredScopes().length);
}
@Test
void compileFromAnnotations_optionalAuth_createsOptionalPolicy() {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(OptionalHandler.class);
assertNotNull(policy);
assertTrue(policy.optionalAuth());
}
@Test
void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(CombinedHandler.class);
assertNotNull(policy);
assertFalse(policy.optionalAuth());
assertArrayEquals(new String[]{"admin"}, policy.requiredRoles());
assertArrayEquals(new String[]{"orders:read", "payments:read"}, policy.requiredScopes());
assertEquals(ScopesAllowed.Match.ALL, policy.scopeMatch());
}
@Test
void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(ScopesHandler.class);
assertNotNull(policy);
assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes());
assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch());
}
@Test
void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() {
assertThrows(IllegalStateException.class,
() -> OidcAuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class));
}
@Test
void openApiScopesFor_returnsScopesWhenPresent() {
assertEquals(List.of("orders:write", "payments:write"),
OidcAuthPolicy.openApiScopesFor(ScopesHandler.class));
}
@Test
void openApiScopesFor_rolesOnly_returnsEmptyList() {
assertEquals(List.of(), OidcAuthPolicy.openApiScopesFor(RolesHandler.class));
}
@Test
void openApiScopesFor_noSecurity_returnsNull() {
assertNull(OidcAuthPolicy.openApiScopesFor(PlainHandler.class));
}
}
@@ -1,72 +0,0 @@
package dev.relism.flash.ext.oidc;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class OidcMiddlewareAuthzTest {
private static OidcMiddleware middleware(String rolesPath, String scopePaths) {
OidcConfig cfg = OidcConfig.builder("https://idp.example.com", "client", "secret", "/auth/callback")
.rolesClaimPath(rolesPath)
.scopeClaimPaths(scopePaths)
.build();
return new OidcMiddleware(null, cfg, null, null);
}
@Test
void rolesAllowed_readsConfiguredNestedClaimPath() {
OidcMiddleware mw = middleware("realm_access.roles", "scope,scp");
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("user", "admin")));
assertTrue(mw.rolesAllowed(claims, new String[]{"admin"}));
assertFalse(mw.rolesAllowed(claims, new String[]{"ops"}));
}
@Test
void scopesAllowed_all_requiresEveryScope() {
OidcMiddleware mw = middleware("roles", "scope,scp");
Map<String, Object> claims = Map.of("scope", "openid profile orders:read");
assertTrue(mw.scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL));
assertFalse(mw.scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL));
}
@Test
void scopesAllowed_any_acceptsAnyConfiguredScopeSource() {
OidcMiddleware mw = middleware("roles", "scope,scp,permissions.scopes");
Map<String, Object> claims = Map.of(
"scp", List.of("payments:write"),
"permissions", Map.of("scopes", "orders:approve")
);
assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve", "orders:read"}, ScopesAllowed.Match.ANY));
assertTrue(mw.scopesAllowed(claims, new String[]{"payments:write"}, ScopesAllowed.Match.ANY));
assertFalse(mw.scopesAllowed(claims, new String[]{"unknown"}, ScopesAllowed.Match.ANY));
}
@Test
void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() {
assertEquals("abc.def.ghi", OidcMiddleware.extractBearerToken("Bearer abc.def.ghi"));
assertEquals("abc", OidcMiddleware.extractBearerToken(" bearer abc "));
assertNull(OidcMiddleware.extractBearerToken("Basic Zm9vOmJhcg=="));
assertNull(OidcMiddleware.extractBearerToken("Bearer"));
}
@Test
void bearerChallenge_containsRealmAndRfcErrors() {
OidcMiddleware mw = middleware("roles", "scope,scp");
String basic = mw.bearerChallenge();
String invalid = mw.invalidTokenChallenge();
String insufficient = mw.insufficientScopeChallenge(new String[]{"orders:read", "payments:write"});
assertTrue(basic.startsWith("Bearer realm=\""));
assertTrue(invalid.contains("error=\"invalid_token\""));
assertTrue(insufficient.contains("error=\"insufficient_scope\""));
assertTrue(insufficient.contains("scope=\"orders:read payments:write\""));
}
}
@@ -1,124 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.ext.openapi.OpenApiContributor;
import dev.relism.flash.extension.FlashContext;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OidcOpenApiInteropTest {
@Authenticated
static class AuthOnly {}
@Authenticated(optional = true)
static class AuthOptional {}
@RolesAllowed("admin")
static class OneRole {}
@RolesAllowed({"admin", "operator"})
static class MultiRole {}
@ScopesAllowed("orders:write")
static class OneScope {}
@ScopesAllowed({"orders:write", "payments:write"})
static class MultiScope {}
@RolesAllowed("admin")
@ScopesAllowed("orders:write")
static class RoleAndScope {}
@Test
void autoResponses_authOnly() throws Exception {
Map<Integer, String> responses = responses(AuthOnly.class);
assertEquals("Authentication required", responses.get(401));
assertFalse(responses.containsKey(403));
}
@Test
void autoResponses_optionalAuth_addsNothing() throws Exception {
Map<Integer, String> responses = responses(AuthOptional.class);
assertTrue(responses.isEmpty());
}
@Test
void autoResponses_oneRole_formatsSingular() throws Exception {
Map<Integer, String> responses = responses(OneRole.class);
assertEquals("Authentication required", responses.get(401));
assertEquals("\"admin\" role required", responses.get(403));
}
@Test
void autoResponses_multiRoles_formatsPlural() throws Exception {
Map<Integer, String> responses = responses(MultiRole.class);
assertEquals("Roles \"admin, operator\" are required", responses.get(403));
}
@Test
void autoResponses_oneScope_formatsSingular() throws Exception {
Map<Integer, String> responses = responses(OneScope.class);
assertEquals("\"orders:write\" scope required", responses.get(403));
}
@Test
void autoResponses_multiScopes_formatsPlural() throws Exception {
Map<Integer, String> responses = responses(MultiScope.class);
assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403));
}
@Test
void autoResponses_roleAndScope_combinesMessages() throws Exception {
Map<Integer, String> responses = responses(RoleAndScope.class);
assertEquals("\"admin\" role required; \"orders:write\" scope required", responses.get(403));
}
@Test
void securityContribution_presentForAuthenticatedHandler() throws Exception {
OpenApiOperationContribution operation = contributor().operationFor(AuthOnly.class);
List<Map<String, List<String>>> security = operation.security();
assertEquals(1, security.size());
assertTrue(security.getFirst().containsKey("issuer"));
}
private static OpenApiContributor contributor() throws Exception {
Class<?> clazz = Class.forName("dev.relism.flash.ext.oidc.OidcExtension$OpenApiIntegration");
Constructor<?> ctor = clazz.getDeclaredConstructor();
ctor.setAccessible(true);
Object instance = ctor.newInstance();
Method m = clazz.getDeclaredMethod("register", FlashContext.class, OidcConfig.class, OidcProviderMetadata.class);
m.setAccessible(true);
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
ctx.complete();
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
m.invoke(instance, ctx, config, meta);
return registry.contributors().getFirst();
}
private static Map<Integer, String> responses(Class<?> cls) throws Exception {
Map<Integer, OpenApiResponseContribution> byCode = contributor().operationFor(cls).responses();
java.util.LinkedHashMap<Integer, String> out = new java.util.LinkedHashMap<>();
for (Map.Entry<Integer, OpenApiResponseContribution> e : byCode.entrySet()) {
out.put(e.getKey(), e.getValue().description());
}
return out;
}
}
@@ -1,47 +0,0 @@
package dev.relism.flash.ext.oidc;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class OidcUserScopesTest {
@Test
void scopes_readsStandardScopeString() {
OidcUser user = new OidcUser(Map.of("scope", "openid profile orders:read"));
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes());
assertTrue(user.hasScope("orders:read"));
assertFalse(user.hasScope("orders:write"));
}
@Test
void scopes_fallsBackToScpArray() {
OidcUser user = new OidcUser(Map.of("scp", List.of("orders:write", "payments:write")));
assertEquals(List.of("orders:write", "payments:write"), user.scopes());
assertTrue(user.hasScope("payments:write"));
}
@Test
void scopes_supportsCustomClaimPaths() {
OidcUser user = new OidcUser(Map.of("permissions", Map.of("scopes", List.of("a", "b"))));
assertEquals(List.of("a", "b"), user.scopes("permissions.scopes"));
assertTrue(user.hasScope("permissions.scopes", "a"));
assertFalse(user.hasScope("permissions.scopes", "x"));
}
@Test
void scopes_combinesMultipleClaimPathsInOrder() {
OidcUser user = new OidcUser(Map.of(
"scope", "openid",
"scp", List.of("profile", "orders:read")
));
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes("scope,scp"));
}
}
+4 -8
View File
@@ -121,15 +121,11 @@ Merge policy:
- contributor collisions use **last-wins**
- manual `@APIResponse` description always wins over contributors for the same status
## OIDC interop
## Security interop
When `flash-ext-oidc` is installed, OpenAPI integrates automatically:
- security scheme under `components.securitySchemes`
- per-operation `security`
- auto responses (class-based handlers):
- `401 Authentication required`
- `403` role/scope required messages when applicable
With `flash-ext-security-core` installed, every registered mechanism's scheme lands under
`components.securitySchemes`, and every operation carrying a security annotation lists them as
`security` alternatives with automatic `401` and — for roles or scopes — `403` responses.
Manual `@APIResponse` for the same status code always wins.
@@ -176,7 +176,8 @@ public final class OpenApiBuilder {
}
if (responseByCode.isEmpty()) {
responseByCode.put(200, Map.of("description", "OK"));
// Mutable: contributors merge descriptions and headers into it.
responseByCode.put(200, new LinkedHashMap<>(Map.of("description", "OK")));
}
applyContributorResponses(responseByCode, cls);
@@ -101,6 +101,15 @@ class OpenApiBuilderTest {
}
}
@GET("/plain")
@ApiOperation(summary = "Plain")
static class PlainHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@Schema(name = "UserDTO", title = "User model", description = "DTO", deprecated = true)
@JsonIgnoreProperties({"ignoredByType"})
static class UserDto {
@@ -341,6 +350,34 @@ class OpenApiBuilderTest {
assertEquals("new", header.get("description"));
}
@Test
void contributor_merges_into_the_default_response_of_an_operation_without_annotations() {
OpenApiBuilder b = new OpenApiBuilder();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
registry.add(new OpenApiContributor() {
@Override
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
return OpenApiOperationContribution.builder()
.allResponses(OpenApiResponseContribution.builder()
.header("X-Trace", Map.of("schema", Map.of("type", "string")))
.build())
.response(401, OpenApiResponseContribution.of("Authentication required"))
.build();
}
});
b.setContributorRegistry(registry);
b.addOperation(OpenApiBuilder.routeOf(PlainHandler.class), PlainHandler.class.getAnnotation(ApiOperation.class), PlainHandler.class);
Map<String, Object> spec = b.build();
Map<String, Object> responses = cast(getOperation(spec, "/plain", "get").get("responses"));
Map<String, Object> resp200 = cast(responses.get("200"));
Map<String, Object> resp401 = cast(responses.get("401"));
Map<String, Object> headers = cast(resp200.get("headers"));
assertEquals("OK", resp200.get("description"));
assertTrue(headers.containsKey("X-Trace"));
assertEquals("Authentication required", resp401.get("description"));
}
private static Map<String, Object> getOperation(Map<String, Object> spec, String path, String method) {
Map<String, Object> paths = cast(spec.get("paths"));
Map<String, Object> pathItem = cast(paths.get(path));
@@ -102,7 +102,7 @@ public record RouteRecord(
/**
* Strips the synthetic lambda suffix ({@code $$Lambda/0x...}) from class names
* so that {@code OidcMiddleware$$Lambda/0x0000019c381f} becomes {@code OidcMiddleware}.
* so that {@code SecurityExtension$$Lambda/0x0000019c381f} becomes {@code SecurityExtension}.
*/
private static List<String> buildMiddlewareNames(List<Class<? extends Middleware>> chain) {
List<String> names = new ArrayList<>(chain.size());
@@ -0,0 +1,20 @@
# flash-ext-security-apikey
API keys for [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md), sent as
`Authorization: Bearer <prefix>_<id>.<secret>`.
```java
ApiKeyExtension<Grant> apiKeys = new ApiKeyExtension<>("gk", id -> rows.find(id)); // ApiKeyStore<Grant>
app.install(new SecurityExtension().roles(...)).install(apiKeys);
GeneratedApiKey key = apiKeys.generate(); // show key.token() once
rows.save(key.id(), key.secretHash(), grant); // never the token
```
The store returns `ApiKey<G>(id, secretHash, grant, expiresAt, revokedAt)`; `G` is whatever the
application authorizes on. An authenticated caller is an `ApiKeyPrincipal<G>` carrying that grant —
read it in a `RoleResolver` with `identity.principal(ApiKeyPrincipal.class)`.
A bearer token without this prefix is left to other mechanisms; one with it that fails — unknown id,
wrong secret, expired, revoked — is a `401 invalid_token`. Only a SHA-256 of the secret is stored: the
secret is 192 random bits, so a slow KDF would protect nothing and cost every request.
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-security-apikey</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-core</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,17 @@
package dev.relism.flash.ext.security.apikey;
import java.time.Instant;
/**
* An API key as the application stores it: never the secret, only its hash, and the grant it was
* issued with whatever the application authorizes on.
*
* @param expiresAt {@code null} for a key that does not expire
* @param revokedAt {@code null} for a key that has not been revoked
*/
public record ApiKey<G>(String id, String secretHash, G grant, Instant expiresAt, Instant revokedAt) {
boolean isActive() {
return revokedAt == null && (expiresAt == null || expiresAt.toEpochMilli() > System.currentTimeMillis());
}
}
@@ -0,0 +1,97 @@
package dev.relism.flash.ext.security.apikey;
import dev.relism.flash.ext.security.AuthenticationFailedException;
import dev.relism.flash.ext.security.AuthenticationMechanism;
import dev.relism.flash.ext.security.Principal;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityScheme;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.models.Request;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;
/**
* API keys sent as {@code Authorization: Bearer <prefix>_<id>.<secret>}. The prefix makes a key
* recognisable at a glance and to secret scanners; the id is what the store is queried by; only a
* SHA-256 of the secret is ever stored a KDF would add nothing to 192 random bits.
*
* <pre>{@code
* app.install(new SecurityExtension())
* .install(new ApiKeyExtension<>("gk", keys::find));
* }</pre>
*/
public final class ApiKeyExtension<G> implements FlashExtension, AuthenticationMechanism {
private static final AuthenticationFailedException INVALID = new AuthenticationFailedException("Bearer error=\"invalid_token\"");
private static final SecureRandom RANDOM = new SecureRandom();
private static final Base64.Encoder BASE64URL = Base64.getUrlEncoder().withoutPadding();
private final String prefix;
private final String bearer;
private final ApiKeyStore<G> store;
/** @param prefix identifies this application's keys; letters and digits only */
public ApiKeyExtension(String prefix, ApiKeyStore<G> store) {
if (!prefix.matches("[A-Za-z0-9]+")) throw new IllegalArgumentException("API key prefix must be alphanumeric: " + prefix);
this.prefix = prefix;
this.bearer = "Bearer " + prefix + "_";
this.store = store;
}
/** A new key: 72 bits of id, 192 bits of secret. */
public GeneratedApiKey generate() {
String id = random(9);
String secret = random(24);
return new GeneratedApiKey(id, prefix + "_" + id + "." + secret, hash(secret));
}
@Override
public Principal authenticate(Request req) {
String header = req.header("Authorization");
if (header == null || !header.startsWith(bearer)) return null;
int dot = header.indexOf('.', bearer.length());
if (dot < 0) throw INVALID;
ApiKey<G> key = store.find(header.substring(bearer.length(), dot));
if (key == null || !matches(header.substring(dot + 1), key.secretHash()) || !key.isActive()) throw INVALID;
return new ApiKeyPrincipal<>(key.id(), key.grant());
}
@Override
public SecurityScheme scheme() {
return SecurityScheme.bearer("apiKey", prefix + "_<id>.<secret>");
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.provide(ApiKeyExtension.class, this);
ctx.onReady(() -> ctx.require(SecurityExtension.class).mechanism(this));
}
private static boolean matches(String secret, String secretHash) {
return MessageDigest.isEqual(digest(secret), Base64.getUrlDecoder().decode(secretHash));
}
private static String hash(String secret) {
return BASE64URL.encodeToString(digest(secret));
}
private static byte[] digest(String secret) {
try {
return MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.US_ASCII));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException(impossible);
}
}
private static String random(int bytes) {
byte[] value = new byte[bytes];
RANDOM.nextBytes(value);
return BASE64URL.encodeToString(value);
}
}
@@ -0,0 +1,6 @@
package dev.relism.flash.ext.security.apikey;
import dev.relism.flash.ext.security.Principal;
/** A caller authenticated by an API key, carrying the grant the key was issued with. */
public record ApiKeyPrincipal<G>(String name, G grant) implements Principal {}
@@ -0,0 +1,9 @@
package dev.relism.flash.ext.security.apikey;
/** Where the application keeps its API keys. */
@FunctionalInterface
public interface ApiKeyStore<G> {
/** The key with this id, or {@code null}. */
ApiKey<G> find(String id);
}
@@ -0,0 +1,7 @@
package dev.relism.flash.ext.security.apikey;
/**
* A freshly generated key. {@code token} goes to the caller exactly once; the application stores
* {@code id} and {@code secretHash}, never the token.
*/
public record GeneratedApiKey(String id, String token, String secretHash) {}
@@ -0,0 +1,57 @@
package dev.relism.flash.ext.security.apikey;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.ext.security.SecurityPolicy;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
class ApiKeyExtensionTest {
static final Map<String, ApiKey<String>> KEYS = new ConcurrentHashMap<>();
static final SecurityExtension security = new SecurityExtension();
static final ApiKeyExtension<String> apiKeys = new ApiKeyExtension<>("fk", KEYS::get);
@RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash
.install(security)
.install(apiKeys)
.get("/grant", (req, res) -> SecurityIdentity.current().principal(ApiKeyPrincipal.class).grant(),
security.enforce(SecurityPolicy.AUTHENTICATED)));
static String issue(String grant, Instant expiresAt, Instant revokedAt) {
GeneratedApiKey key = apiKeys.generate();
KEYS.put(key.id(), new ApiKey<>(key.id(), key.secretHash(), grant, expiresAt, revokedAt));
return "Bearer " + key.token();
}
@Test
void anIssuedKeyAuthenticatesWithItsGrant() {
app.request().header("Authorization", issue("project-42", null, null)).get("/grant").expectStatus(200).expectBody("project-42");
}
@Test
void aWrongSecretAnExpiredKeyAndARevokedKeyAreRejectedAsInvalid() {
String valid = issue("x", null, null);
for (String token : new String[]{
valid.substring(0, valid.length() - 1) + (valid.endsWith("A") ? "B" : "A"),
issue("x", Instant.now().minusSeconds(1), null),
issue("x", null, Instant.now()),
"Bearer fk_no-secret-here"}) {
app.request().header("Authorization", token).get("/grant")
.expectStatus(401).expectHeader("WWW-Authenticate", "Bearer error=\"invalid_token\"");
}
}
/** Another application's bearer token is not a key of ours: it is left to other mechanisms, not rejected. */
@Test
void aForeignBearerTokenIsNotThisMechanisms() {
app.request().header("Authorization", "Bearer eyJhbGciOi.payload.signature").get("/grant")
.expectStatus(401).expectHeader("WWW-Authenticate", "Bearer realm=\"apiKey\"");
}
}
@@ -0,0 +1,88 @@
# flash-ext-security-core
Authentication and authorization for Flash, independent of any credential. Mechanisms —
[`-oidc`](../../flash-ext-security-oidc/docs/README.md), [`-apikey`](../../flash-ext-security-apikey/docs/README.md),
[`-form`](../../flash-ext-security-form/docs/README.md), or your own — register into one chain;
this module owns everything downstream of "who is the caller".
```java
app.install(new SecurityExtension()
.users(principal -> users.findOrProvision(principal)) // optional: principal → your user
.roles((identity, role, on) -> members.has(identity.user(User.class), role, on.get("project"))))
.install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)));
```
## The model
| Type | Role |
|---|---|
| `AuthenticationMechanism` | reads one kind of credential: returns a `Principal`, `null` (not mine), or throws `AuthenticationFailedException` (mine, invalid) |
| `Principal` | who the mechanism proved the caller to be — typed per mechanism (`OidcPrincipal`, `ApiKeyPrincipal`, …) |
| `SecurityIdentity` | the current caller: `principal(OidcPrincipal.class)`, `user(User.class)`, `hasRole`, `hasScope` |
| `UserResolver` | principal → application user, resolved lazily, once per request |
| `RoleResolver` | whether a caller holds a role, optionally on a resource |
| `AuthenticationEntryPoint` | the answer to a request that needs a caller and carries no credential |
Mechanisms never write the response. That is what keeps the one mistake that matters impossible to
make: a credential that was presented and rejected is always a 401, never a redirect into a sign-in
page an API client cannot parse.
## Annotations
On a handler or an MCP tool class:
| | |
|---|---|
| `@Authenticated` | any authenticated caller |
| `@PermitAll` | anyone; a caller who authenticates is still identified, one who fails is anonymous |
| `@RolesAllowed(value, on)` | any of the roles; `on` names the path/query parameters (tool arguments on MCP) identifying the resource |
| `@ScopesAllowed(value)` | every one of the credential's scopes |
`@RolesAllowed(value = "MANAGER", on = "project")` on `/projects/{project}/keys` asks the
`RoleResolver` whether the caller is a manager *of that project*. A handler declaring roles with no
`RoleResolver` configured fails the boot. Policies compile once; checking one allocates nothing.
## The chain
Mechanisms are tried in registration order, then the session cookie. The first to return a
principal wins. When none does:
- a browser (`Accept: text/html`) is redirected to the only login method, or to `loginPage` when there are several;
- anything else gets `401` with every mechanism's challenge in `WWW-Authenticate`.
`entryPoint(...)` replaces that, e.g. to pick an identity provider from the user's email domain.
## Sessions
`signIn(req, res, principal[, expiresAt])` stores the principal under a `flash_session` cookie;
`POST /auth/logout` ends it and follows `Principal.logoutUrl()`. An expired session is handed to the
`SessionRefresher` registered for its principal type, or ended. `InMemorySessionStore` is the
default; `sessions(...)` swaps it for one that survives a restart or spans instances.
`GET /auth/methods` lists every registered `LoginMethod` for a client to render.
## OpenAPI
With `flash-ext-openapi` present, every registered mechanism's `SecurityScheme` is published, and
every protected operation lists them as alternatives, with its 401 and — for roles or scopes — its
403 and what it requires. Nothing to write per mechanism.
## Writing a mechanism
```java
security.mechanism(new AuthenticationMechanism() {
public Principal authenticate(Request req) {
String key = req.header("X-Key");
if (key == null) return null; // not mine
Principal p = keys.get(key);
if (p == null) throw new AuthenticationFailedException(null); // mine, and invalid
return p;
}
public SecurityScheme scheme() { return SecurityScheme.bearer("key", "opaque"); }
});
```
## Testing
[`flash-ext-security-test`](../../flash-ext-security-test/docs/README.md) authenticates requests as
any principal without an identity provider.
@@ -10,7 +10,7 @@
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-oidc</artifactId>
<artifactId>flash-ext-security-core</artifactId>
<dependencies>
<dependency>
@@ -22,22 +22,14 @@
<artifactId>flash-ext-openapi</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
</dependency>
<dependency>
<groupId>net.minidev</groupId>
<artifactId>json-smart</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,12 @@
package dev.relism.flash.ext.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/** The handler or MCP tool requires an authenticated caller. */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@java.lang.annotation.Target(ElementType.TYPE)
public @interface Authenticated {}
@@ -0,0 +1,11 @@
package dev.relism.flash.ext.security;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
/** Answers a request that needs a caller and carries no credential at all. */
@FunctionalInterface
public interface AuthenticationEntryPoint {
Object commence(Request req, Response res) throws Exception;
}
@@ -0,0 +1,24 @@
package dev.relism.flash.ext.security;
import dev.relism.flash.exceptions.HttpException;
/** A credential was presented and rejected. Stackless: turning away forged tokens stays cheap. */
public final class AuthenticationFailedException extends HttpException {
private final String challenge;
/** @param challenge the {@code WWW-Authenticate} value to answer with, or {@code null} */
public AuthenticationFailedException(String challenge) {
super(401, "Unauthorized");
this.challenge = challenge;
}
public String challenge() {
return challenge;
}
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
}
@@ -0,0 +1,21 @@
package dev.relism.flash.ext.security;
import dev.relism.flash.models.Request;
/**
* Reads one kind of credential off a request. A mechanism never writes the response: an anonymous
* request is answered by the {@link AuthenticationEntryPoint}, a rejected one by the 401 its
* {@link AuthenticationFailedException} carries.
*/
public interface AuthenticationMechanism {
/**
* The caller, or {@code null} when the request carries no credential of this kind.
*
* @throws AuthenticationFailedException the request carries one, and it is invalid
*/
Principal authenticate(Request req);
/** How OpenAPI documents the credential and a 401 challenges for it; {@code null} for neither. */
default SecurityScheme scheme() { return null; }
}
@@ -0,0 +1,26 @@
package dev.relism.flash.ext.security;
import java.util.concurrent.ConcurrentHashMap;
/** One instance's sessions, lost on restart. Expired ones are swept whenever a session is saved. */
public final class InMemorySessionStore implements SessionStore {
private final ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>();
@Override
public void save(Session session) {
long now = System.currentTimeMillis();
sessions.values().removeIf(s -> s.expiresAt().toEpochMilli() <= now);
sessions.put(session.id(), session);
}
@Override
public Session find(String id) {
return sessions.get(id);
}
@Override
public void delete(String id) {
sessions.remove(id);
}
}
@@ -0,0 +1,12 @@
package dev.relism.flash.ext.security;
/** A way to sign in, listed at {@code GET /auth/methods} for a client to offer. */
public record LoginMethod(String id, String name, String url, Kind kind) {
public enum Kind {
/** A browser navigates to {@code url} and comes back signed in — OpenID Connect, for instance. */
REDIRECT,
/** A client posts {@code username} and {@code password} to {@code url}. */
FORM
}
}
@@ -0,0 +1,15 @@
package dev.relism.flash.ext.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* Anyone may call the handler. A caller who authenticates is still identified; one whose credential
* is rejected is treated as anonymous rather than refused.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@java.lang.annotation.Target(ElementType.TYPE)
public @interface PermitAll {}
@@ -0,0 +1,20 @@
package dev.relism.flash.ext.security;
/**
* Who an {@link AuthenticationMechanism} proved the caller to be. Each mechanism has its own type;
* {@link SecurityIdentity#principal(Class)} reads it back.
*/
public interface Principal {
/** Unique within the mechanism that produced it. */
String name();
/** Whether the credential grants {@code scope}. One that carries no scopes grants every scope. */
default boolean hasScope(String scope) { return true; }
/** Whether the credential was issued for {@code audience} (RFC 8707). One bound to no audience was. */
default boolean hasAudience(String audience) { return true; }
/** Where signing out sends the browser; {@code null} for the application root. */
default String logoutUrl() { return null; }
}
@@ -0,0 +1,11 @@
package dev.relism.flash.ext.security;
/**
* Whether a caller holds a role read from a token, a database, anywhere. {@code on} identifies
* the resource for roles held per resource rather than globally.
*/
@FunctionalInterface
public interface RoleResolver {
boolean hasRole(SecurityIdentity identity, String role, Target on);
}
@@ -0,0 +1,24 @@
package dev.relism.flash.ext.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* The caller must hold at least one of these roles, as decided by the configured
* {@link RoleResolver}. Implies {@link Authenticated}.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@java.lang.annotation.Target(ElementType.TYPE)
public @interface RolesAllowed {
String[] value();
/**
* Names of the path or query parameters (tool arguments on MCP) that identify the resource the
* role is held on {@code on = "project"} checks the role on {@code /projects/{project}}.
*/
String[] on() default {};
}
@@ -0,0 +1,15 @@
package dev.relism.flash.ext.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/** The caller's credential must grant every one of these scopes. Implies {@link Authenticated}. */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@java.lang.annotation.Target(ElementType.TYPE)
public @interface ScopesAllowed {
String[] value();
}
@@ -0,0 +1,321 @@
package dev.relism.flash.ext.security;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.openapi.OpenApiContributor;
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.fpr.core.ByteView;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Flash security: the authentication chain, the policies security annotations declare, sessions,
* and the {@code /auth/logout} and {@code /auth/methods} routes. Mechanisms register through
* {@link #mechanism}, directly or from their own extensions, and are tried in registration order
* before the session cookie.
*
* <pre>{@code
* app.install(new SecurityExtension().users(users).roles(roles))
* .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)));
* }</pre>
*/
public class SecurityExtension implements FlashExtension {
/** The node security annotations mount under, for middleware that must run before or after it. */
public static final MiddlewareKey POLICY = MiddlewareKey.of("flash.security.policy");
private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
private static final String COOKIE = "flash_session";
private static final SecureRandom RANDOM = new SecureRandom();
private final Map<Class<?>, SessionRefresher> refreshers = new ConcurrentHashMap<>();
private volatile AuthenticationMechanism[] mechanisms = {};
private volatile SecurityScheme[] schemes = {};
private volatile LoginMethod[] loginMethods = {};
private volatile String challenges;
private volatile String methodsJson = "[]";
UserResolver<?> users = principal -> principal;
RoleResolver roles;
private AuthenticationEntryPoint entryPoint = this::commence;
private SessionStore sessions = new InMemorySessionStore();
private Duration sessionTimeout = Duration.ofHours(12);
private String loginPage = "/login";
// -- Configuration --------------------------------------------------------
/** Resolves {@link SecurityIdentity#user} — default: the principal itself. */
public SecurityExtension users(UserResolver<?> users) {
this.users = users;
return this;
}
/** Required by {@link RolesAllowed}; a handler that declares roles without one fails the boot. */
public SecurityExtension roles(RoleResolver roles) {
this.roles = roles;
return this;
}
/** Replaces the default: a browser is redirected to sign in, anything else gets 401 with every challenge. */
public SecurityExtension entryPoint(AuthenticationEntryPoint entryPoint) {
this.entryPoint = entryPoint;
return this;
}
public SecurityExtension sessions(SessionStore sessions) {
this.sessions = sessions;
return this;
}
public SecurityExtension sessionTimeout(Duration sessionTimeout) {
this.sessionTimeout = sessionTimeout;
return this;
}
/** Where a browser signs in, unless the only {@link LoginMethod} is a redirect it can follow directly. */
public SecurityExtension loginPage(String loginPage) {
this.loginPage = loginPage;
return this;
}
// -- Registration (boot time) ---------------------------------------------
public synchronized SecurityExtension mechanism(AuthenticationMechanism mechanism) {
mechanisms = append(mechanisms, mechanism);
return mechanism.scheme() == null ? this : scheme(mechanism.scheme());
}
/** Documents and challenges for a credential beyond the one {@link AuthenticationMechanism#scheme()} names. */
public synchronized SecurityExtension scheme(SecurityScheme scheme) {
schemes = append(schemes, scheme);
challenges = challenges == null ? scheme.challenge() : challenges + ", " + scheme.challenge();
return this;
}
public synchronized SecurityExtension loginMethod(LoginMethod method) {
loginMethods = append(loginMethods, method);
StringBuilder json = new StringBuilder("[");
for (LoginMethod m : loginMethods) {
if (json.length() > 1) json.append(',');
json.append("{\"id\":\"").append(m.id()).append("\",\"name\":\"").append(m.name())
.append("\",\"url\":\"").append(m.url()).append("\",\"kind\":\"").append(m.kind().name().toLowerCase()).append("\"}");
}
methodsJson = json.append(']').toString();
return this;
}
public SecurityExtension refresher(Class<? extends Principal> type, SessionRefresher refresher) {
refreshers.put(type, refresher);
return this;
}
/** The schemes of every registered mechanism, in registration order. */
public List<SecurityScheme> schemes() {
return List.of(schemes);
}
// -- Runtime --------------------------------------------------------------
/**
* The caller, or {@code null} when no mechanism recognises a credential.
*
* @throws AuthenticationFailedException a mechanism recognised one and rejected it
*/
public SecurityIdentity authenticate(Request req) {
for (AuthenticationMechanism mechanism : mechanisms) {
Principal principal = mechanism.authenticate(req);
if (principal != null) return new SecurityIdentity(principal, this, req);
}
Principal principal = sessionPrincipal(req);
return principal == null ? null : new SecurityIdentity(principal, this, req);
}
/**
* The policy {@code type}'s annotations declare, checked against this configuration declaring
* roles without a {@link RoleResolver} fails here, at boot. {@code null} for no annotations.
*/
public SecurityPolicy policy(Class<?> type) {
SecurityPolicy policy = SecurityPolicy.of(type);
if (policy != null && policy.requiresRoles() && roles == null) {
throw new IllegalStateException(type.getName() + " declares @RolesAllowed, but no RoleResolver is configured — SecurityExtension.roles(...)");
}
return policy;
}
public Middleware enforce(SecurityPolicy policy) {
return enforce(policy, entryPoint);
}
/** {@code anonymous} answers a caller without credentials on this route instead of the configured entry point. */
public Middleware enforce(SecurityPolicy policy, AuthenticationEntryPoint anonymous) {
return next -> (req, res) -> {
SecurityIdentity identity;
try {
identity = authenticate(req);
} catch (AuthenticationFailedException rejected) {
if (policy.required) {
if (rejected.challenge() != null) res.header(WWW_AUTHENTICATE, rejected.challenge());
throw rejected;
}
identity = null;
}
if (identity == null) {
if (policy.required) return anonymous.commence(req, res);
} else if (!policy.permitsScopes(identity)) {
res.header(WWW_AUTHENTICATE, policy.scopeChallenge);
throw HttpException.forbidden();
} else if (!policy.permitsRoles(identity, policy.on.length == 0 ? Target.NONE : name -> {
String value = req.param(name);
return value != null ? value : req.query(name);
})) {
throw HttpException.forbidden();
}
SecurityIdentity.CURRENT.set(identity);
try {
return next.handle(req, res);
} finally {
SecurityIdentity.CURRENT.remove();
}
};
}
/** Starts a session for {@code principal} lasting the configured timeout. */
public void signIn(Request req, Response res, Principal principal) {
signIn(req, res, principal, Instant.now().plus(sessionTimeout));
}
public void signIn(Request req, Response res, Principal principal, Instant expiresAt) {
byte[] id = new byte[24];
RANDOM.nextBytes(id);
Session session = new Session(Base64.getUrlEncoder().withoutPadding().encodeToString(id), principal, expiresAt);
sessions.save(session);
res.header("Set-Cookie", COOKIE + "=" + session.id() + "; Path=/; HttpOnly; SameSite=Lax" + (req.origin().startsWith("https") ? "; Secure" : ""));
}
/** Ends the caller's session and returns where the browser goes next. */
public String signOut(Request req, Response res) {
String id = req.cookie(COOKIE);
Session session = id == null ? null : sessions.find(id);
if (session != null) sessions.delete(id);
res.header("Set-Cookie", COOKIE + "=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax");
String next = session == null ? null : session.principal().logoutUrl();
return next == null ? "/" : next;
}
// -- Extension ------------------------------------------------------------
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.provide(SecurityExtension.class, this);
ctx.addAnnotationProcessor(handler -> {
SecurityPolicy policy = policy(handler);
return policy == null ? List.of() : List.of(MiddlewareNode.of(POLICY, enforce(policy)));
});
app.post("/auth/logout", (req, res) -> {
res.status(303).header("Location", signOut(req, res));
return null;
});
app.get("/auth/methods", (req, res) -> {
res.type(ContentType.JSON);
return methodsJson;
});
ctx.onReady(() -> {
try {
OpenApi.register(ctx, this);
} catch (NoClassDefFoundError absent) {
// flash-ext-openapi is not on the classpath
}
});
}
private Object commence(Request req, Response res) {
LoginMethod[] methods = loginMethods;
String accept = req.header("Accept");
if (methods.length > 0 && accept != null && accept.contains("text/html")) {
String login = methods.length == 1 && methods[0].kind() == LoginMethod.Kind.REDIRECT ? methods[0].url() : loginPage;
ByteView query = req.getRequestLine().getQuery();
byte[] raw = new byte[query == null ? 0 : query.length()];
for (int i = 0; i < raw.length; i++) raw[i] = query.byteAt(i);
String target = raw.length == 0 ? req.path() : req.path() + "?" + new String(raw, StandardCharsets.UTF_8);
res.redirect(login + "?redirect=" + URLEncoder.encode(target, StandardCharsets.UTF_8));
return null;
}
if (challenges != null) res.header(WWW_AUTHENTICATE, challenges);
throw HttpException.unauthorized();
}
private Principal sessionPrincipal(Request req) {
String id = req.cookie(COOKIE);
Session session = id == null ? null : sessions.find(id);
if (session == null) return null;
if (session.expiresAt().toEpochMilli() > System.currentTimeMillis()) return session.principal();
SessionRefresher refresher = refreshers.get(session.principal().getClass());
Session renewed = refresher == null ? null : refresher.refresh(session);
if (renewed == null) {
sessions.delete(id);
return null;
}
sessions.save(renewed);
return renewed.principal();
}
private static <T> T[] append(T[] array, T element) {
T[] grown = Arrays.copyOf(array, array.length + 1);
grown[array.length] = element;
return grown;
}
/** Isolated so this extension loads without flash-ext-openapi on the classpath. */
private static final class OpenApi {
static void register(FlashContext ctx, SecurityExtension security) {
ctx.find(OpenApiContributorRegistry.class).ifPresent(registry -> registry.add(new OpenApiContributor() {
@Override
public Map<String, Object> componentContributions() {
Map<String, Object> definitions = new LinkedHashMap<>();
for (SecurityScheme scheme : security.schemes) definitions.put(scheme.name(), scheme.definition());
return definitions.isEmpty() ? Map.of() : Map.of("securitySchemes", definitions);
}
@Override
public OpenApiOperationContribution operationFor(Class<?> handler) {
SecurityPolicy policy = SecurityPolicy.of(handler);
if (policy == null || !policy.required) return OpenApiOperationContribution.empty();
OpenApiOperationContribution.Builder operation = OpenApiOperationContribution.builder();
for (SecurityScheme scheme : security.schemes) {
operation.security(scheme.name(), scheme.issuer() != null ? List.of(policy.scopes) : List.of());
}
operation.response(401, OpenApiResponseContribution.of("Authentication required"));
String roles = policy.roles.length == 0 ? null : "Requires role " + String.join(" or ", policy.roles)
+ (policy.on.length == 0 ? "" : " on " + String.join(", ", policy.on));
String scopes = policy.scopes.length == 0 ? null : "Requires scopes " + String.join(" ", policy.scopes);
if (roles != null || scopes != null) {
operation.response(403, OpenApiResponseContribution.of(
roles == null ? scopes : scopes == null ? roles : roles + "; " + scopes));
}
return operation.build();
}
}));
}
}
}
@@ -0,0 +1,65 @@
package dev.relism.flash.ext.security;
import dev.relism.flash.models.Request;
/**
* The authenticated caller of the current request: the {@link Principal} a mechanism produced, the
* application user it resolves to, and the roles and scopes it holds.
*/
public final class SecurityIdentity {
static final ThreadLocal<SecurityIdentity> CURRENT = new ThreadLocal<>();
private final Principal principal;
private final SecurityExtension security;
private final Request request;
private Object user;
SecurityIdentity(Principal principal, SecurityExtension security, Request request) {
this.principal = principal;
this.security = security;
this.request = request;
}
/** The caller of the request this thread is handling; {@code null} when it is anonymous. */
public static SecurityIdentity current() {
return CURRENT.get();
}
public Principal principal() {
return principal;
}
/**
* The request being authorized. {@link Target} carries what {@link RolesAllowed#on()} names, read
* from path and query parameters; a {@link RoleResolver} whose scope is somewhere else a tenant
* header, say reads it from here.
*/
public Request request() {
return request;
}
/** The principal as {@code type}, or {@code null} when another mechanism authenticated the caller. */
public <P extends Principal> P principal(Class<P> type) {
return type.isInstance(principal) ? type.cast(principal) : null;
}
/** The application user, resolved once per request by the configured {@link UserResolver}. */
public <U> U user(Class<U> type) {
if (user == null) user = security.users.resolve(principal);
return type.cast(user);
}
public boolean hasScope(String scope) {
return principal.hasScope(scope);
}
public boolean hasRole(String role) {
return hasRole(role, Target.NONE);
}
public boolean hasRole(String role, Target on) {
if (security.roles == null) throw new IllegalStateException("No RoleResolver configured — SecurityExtension.roles(...)");
return security.roles.hasRole(this, role, on);
}
}
@@ -0,0 +1,63 @@
package dev.relism.flash.ext.security;
/** What a handler's or tool's security annotations require, compiled once at boot. Checks allocate nothing. */
public final class SecurityPolicy {
/** Any authenticated caller. */
public static final SecurityPolicy AUTHENTICATED = new SecurityPolicy(true, new String[0], new String[0], new String[0]);
final boolean required;
final String[] roles;
final String[] on;
final String[] scopes;
final String scopeChallenge;
private SecurityPolicy(boolean required, String[] roles, String[] on, String[] scopes) {
this.required = required;
this.roles = roles;
this.on = on;
this.scopes = scopes;
this.scopeChallenge = "Bearer error=\"insufficient_scope\", scope=\"" + String.join(" ", scopes) + "\"";
}
/** The policy {@code type} declares, or {@code null} when it carries no security annotation. */
public static SecurityPolicy of(Class<?> type) {
boolean permitAll = type.isAnnotationPresent(PermitAll.class);
boolean authenticated = type.isAnnotationPresent(Authenticated.class);
RolesAllowed roles = type.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = type.getAnnotation(ScopesAllowed.class);
if (!permitAll && !authenticated && roles == null && scopes == null) return null;
if (permitAll && (authenticated || roles != null || scopes != null)) {
throw new IllegalStateException("@PermitAll contradicts the other security annotations on " + type.getName());
}
return new SecurityPolicy(!permitAll,
roles == null ? AUTHENTICATED.roles : values(roles.value(), "@RolesAllowed", type),
roles == null ? AUTHENTICATED.on : roles.on(),
scopes == null ? AUTHENTICATED.scopes : values(scopes.value(), "@ScopesAllowed", type));
}
/** False only for {@link PermitAll}. */
public boolean required() {
return required;
}
public boolean requiresRoles() {
return roles.length > 0;
}
public boolean permitsScopes(SecurityIdentity identity) {
for (String scope : scopes) if (!identity.hasScope(scope)) return false;
return true;
}
public boolean permitsRoles(SecurityIdentity identity, Target target) {
if (roles.length == 0) return true;
for (String role : roles) if (identity.hasRole(role, target)) return true;
return false;
}
private static String[] values(String[] values, String annotation, Class<?> type) {
if (values.length == 0) throw new IllegalStateException(annotation + " on " + type.getName() + " names nothing");
return values;
}
}
@@ -0,0 +1,24 @@
package dev.relism.flash.ext.security;
import java.util.Map;
/**
* A credential as OpenAPI names and defines it, the {@code WWW-Authenticate} challenge an anonymous
* API call receives for it, and for OAuth the issuer that grants it.
*
* @param definition the OpenAPI Security Scheme Object, verbatim
* @param issuer the authorization server's issuer identifier, {@code null} for anything else
*/
public record SecurityScheme(String name, Map<String, Object> definition, String challenge, String issuer) {
public static SecurityScheme bearer(String name, String bearerFormat) {
return new SecurityScheme(name, Map.of("type", "http", "scheme", "bearer", "bearerFormat", bearerFormat),
"Bearer realm=\"" + name + "\"", null);
}
public static SecurityScheme openIdConnect(String name, String issuer) {
return new SecurityScheme(name,
Map.of("type", "openIdConnect", "openIdConnectUrl", issuer + (issuer.endsWith("/") ? "" : "/") + ".well-known/openid-configuration"),
"Bearer realm=\"" + name + "\"", issuer);
}
}
@@ -0,0 +1,6 @@
package dev.relism.flash.ext.security;
import java.time.Instant;
/** A signed-in principal, kept server-side under the id its cookie carries. */
public record Session(String id, Principal principal, Instant expiresAt) {}
@@ -0,0 +1,8 @@
package dev.relism.flash.ext.security;
/** Renews an expired session — with a refresh token, typically. {@code null} ends it instead. */
@FunctionalInterface
public interface SessionRefresher {
Session refresh(Session expired);
}
@@ -0,0 +1,12 @@
package dev.relism.flash.ext.security;
/** Where sessions live. {@link InMemorySessionStore} unless one that survives restarts is configured. */
public interface SessionStore {
void save(Session session);
/** The session, or {@code null}. */
Session find(String id);
void delete(String id);
}
@@ -0,0 +1,15 @@
package dev.relism.flash.ext.security;
/**
* The resource a role is checked on: the values {@link RolesAllowed#on()} names, read from path
* and query parameters on HTTP and from tool arguments on MCP.
*/
@FunctionalInterface
public interface Target {
/** A role checked on nothing in particular. */
Target NONE = name -> null;
/** The value called {@code name}, or {@code null} when the call does not carry one. */
String get(String name);
}
@@ -0,0 +1,8 @@
package dev.relism.flash.ext.security;
/** The application user a verified principal belongs to — typically found, or provisioned, by issuer and subject. */
@FunctionalInterface
public interface UserResolver<U> {
U resolve(Principal principal);
}
@@ -0,0 +1,132 @@
package dev.relism.flash.ext.security;
import dev.relism.flash.ext.openapi.OpenApiExtension;
import dev.relism.flash.models.Request;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.time.Instant;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
class SecurityExtensionTest {
/** {@code Authorization: Key <name>[ <scope>...]}; {@code Key !} is a presented, invalid credential. */
record KeyPrincipal(String name, Set<String> scopes) implements Principal {
@Override public boolean hasScope(String scope) { return scopes.contains(scope); }
}
static final AuthenticationMechanism KEY = new AuthenticationMechanism() {
@Override
public Principal authenticate(Request req) {
String header = req.header("Authorization");
if (header == null || !header.startsWith("Key ")) return null;
String[] parts = header.substring(4).split(" ");
if (parts[0].equals("!")) throw new AuthenticationFailedException("Key error=\"invalid_token\"");
return new KeyPrincipal(parts[0], Set.of(java.util.Arrays.copyOfRange(parts, 1, parts.length)));
}
@Override
public SecurityScheme scheme() {
return SecurityScheme.bearer("key", "opaque");
}
};
static final SecurityExtension security = new SecurityExtension()
.roles((identity, role, on) -> identity.principal().name().equals(role + "@" + on.get("project")))
.mechanism(KEY)
.loginMethod(new LoginMethod("key", "Key", "/auth/key/login", LoginMethod.Kind.REDIRECT))
.refresher(KeyPrincipal.class, expired -> new Session(expired.id(), expired.principal(), Instant.now().plusSeconds(60)));
@RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash
.install(security)
.install(new OpenApiExtension("/openapi", "test", "1"))
.get("/me", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED))
.post("/login", (req, res) -> {
security.signIn(req, res, new KeyPrincipal("carol", Set.of()), Instant.now().minusSeconds(1));
return "signed in";
})
.scan("dev.relism.flash.ext.security.fixtures"));
@Test
void aValidCredentialIdentifiesTheCaller() {
app.request().header("Authorization", "Key alice").get("/me").expectStatus(200).expectBody("alice");
}
/** Rejected is never anonymous: a browser presenting a bad credential must not be sent to sign in. */
@Test
void aRejectedCredentialIs401WithItsOwnChallengeEvenForABrowser() {
app.request().header("Authorization", "Key !").header("Accept", "text/html").get("/me")
.expectStatus(401)
.expectHeader("WWW-Authenticate", "Key error=\"invalid_token\"");
}
@Test
void anApiCallWithoutCredentialsIsChallengedForEveryMechanism() {
app.get("/me").expectStatus(401).expectHeader("WWW-Authenticate", "Bearer realm=\"key\"");
}
@Test
void aBrowserWithoutCredentialsGoesStraightToTheOnlyLoginMethod() {
app.request().header("Accept", "text/html").get("/me?tab=keys")
.expectStatus(302)
.expectHeader("Location", "/auth/key/login?redirect=%2Fme%3Ftab%3Dkeys");
}
@Test
void permitAllIdentifiesWhoeverAuthenticatesAndToleratesEveryoneElse() {
app.request().header("Authorization", "Key bob").get("/open").expectBody("bob");
app.request().header("Authorization", "Key !").get("/open").expectStatus(200).expectBody("anonymous");
app.get("/open").expectBody("anonymous");
}
@Test
void rolesAreCheckedOnTheResourceThePathNames() {
app.request().header("Authorization", "Key MANAGER@42").get("/projects/42").expectStatus(200);
app.request().header("Authorization", "Key MANAGER@42").get("/projects/7").expectStatus(403);
}
@Test
void aMissingScopeIs403WithAnInsufficientScopeChallenge() {
app.request().header("Authorization", "Key dave write").post("/write").expectStatus(200);
app.request().header("Authorization", "Key dave").post("/write")
.expectStatus(403)
.expectHeader("WWW-Authenticate", "Bearer error=\"insufficient_scope\", scope=\"write\"");
}
/** The session is signed in already expired, so reaching /me proves the refresher ran. */
@Test
void aSessionIsRefreshedWhenExpiredAndEndedBySigningOut() {
String cookie = app.request().post("/login").expectStatus(200).header("Set-Cookie");
String session = cookie.substring(0, cookie.indexOf(';'));
app.request().header("Cookie", session).get("/me").expectStatus(200).expectBody("carol");
app.request().header("Cookie", session).post("/auth/logout").expectStatus(303).expectHeader("Location", "/");
app.request().header("Cookie", session).get("/me").expectStatus(401);
}
@Test
void loginMethodsAreListed() {
app.get("/auth/methods").expectStatus(200).expectBody("[{\"id\":\"key\",\"name\":\"Key\",\"url\":\"/auth/key/login\",\"kind\":\"redirect\"}]");
}
@Test
void openApiDocumentsEachSchemeAndWhatEachOperationRequires() {
app.get("/openapi.json").expectStatus(200)
.expectBodyContains("\"securitySchemes\"")
.expectBodyContains("\"bearerFormat\":\"opaque\"")
.expectBodyContains("Requires role MANAGER on project")
.expectBodyContains("Requires scopes write");
}
@Test
void declaringRolesWithoutAResolverFailsTheBoot() {
FlashTest broken = FlashTest.of(flash -> flash
.install(new SecurityExtension())
.scan("dev.relism.flash.ext.security.fixtures"));
assertThrows(Exception.class, () -> broken.get("/open"));
}
}
@@ -0,0 +1,44 @@
package dev.relism.flash.ext.security;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SecurityPolicyTest {
static class Unannotated {}
@PermitAll @RolesAllowed("ADMIN")
static class Contradictory {}
@RolesAllowed({})
static class NoRoles {}
@RolesAllowed("ADMIN") @ScopesAllowed("write")
static class RolesAndScopes {}
@Test
void anUnannotatedTypeHasNoPolicy() {
assertNull(SecurityPolicy.of(Unannotated.class));
}
@Test
void contradictionsAndEmptyRequirementsFailAtCompileTime() {
assertThrows(IllegalStateException.class, () -> SecurityPolicy.of(Contradictory.class));
assertThrows(IllegalStateException.class, () -> SecurityPolicy.of(NoRoles.class));
}
@Test
void rolesAndScopesBothRequireAuthentication() {
SecurityPolicy policy = SecurityPolicy.of(RolesAndScopes.class);
assertTrue(policy.required());
assertTrue(policy.requiresRoles());
assertFalse(SecurityPolicy.of(OpenAccess.class).required());
}
@PermitAll
static class OpenAccess {}
}
@@ -0,0 +1,18 @@
package dev.relism.flash.ext.security.fixtures;
import dev.relism.flash.ext.security.PermitAll;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.GET;
@GET("/open")
@PermitAll
public final class OpenHandler extends RequestHandler {
@Override
public Object handle(Request req, Response res) {
SecurityIdentity identity = SecurityIdentity.current();
return identity == null ? "anonymous" : identity.principal().name();
}
}
@@ -0,0 +1,19 @@
package dev.relism.flash.ext.security.fixtures;
import dev.relism.flash.ext.openapi.ApiOperation;
import dev.relism.flash.ext.security.RolesAllowed;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.GET;
@GET("/projects/{project}")
@ApiOperation(summary = "fixture")
@RolesAllowed(value = "MANAGER", on = "project")
public final class ProjectHandler extends RequestHandler {
@Override
public Object handle(Request req, Response res) {
return SecurityIdentity.current().principal().name();
}
}
@@ -0,0 +1,18 @@
package dev.relism.flash.ext.security.fixtures;
import dev.relism.flash.ext.openapi.ApiOperation;
import dev.relism.flash.ext.security.ScopesAllowed;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.POST;
@POST("/write")
@ApiOperation(summary = "fixture")
@ScopesAllowed("write")
public final class WriteHandler extends RequestHandler {
@Override
public Object handle(Request req, Response res) {
return "written";
}
}
@@ -0,0 +1,17 @@
# flash-ext-security-form
Password sign-in for [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md).
```java
app.install(new SecurityExtension())
.install(new FormLoginExtension(username -> accounts.find(username))); // PasswordStore
```
`POST /auth/form/login` takes `username` and `password` form-encoded, starts a session and answers
`303` to `?redirect=` (same-origin paths only) or `/`. A wrong password and an unknown account are the
same `401`, and cost the same time. The method is listed at `/auth/methods` with `"kind":"form"`, and
the entry point sends browsers to `SecurityExtension.loginPage` to render it.
`PasswordEncoder.pbkdf2()` hashes (PBKDF2-HMAC-SHA256, 600k iterations, JDK only); use it to create
accounts, or pass another encoder to `passwordEncoder(...)`. Rate-limit the route with
`flash-ext-limiter`.
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-security-form</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-core</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,74 @@
package dev.relism.flash.ext.security.form;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.security.LoginMethod;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
/**
* Password sign-in: {@code POST /auth/form/login} with {@code username} and {@code password}
* form-encoded what an HTML form sends, and what {@code fetch} sends given {@code URLSearchParams}.
* Success starts a session and answers {@code 303} to {@code redirect} (same-origin paths only) or
* {@code /}; failure is a {@code 401}, identical for an unknown account and a wrong password.
*
* <pre>{@code
* app.install(new SecurityExtension())
* .install(new FormLoginExtension(accounts::byUsername));
* }</pre>
*
* Rate-limit the route with {@code flash-ext-limiter}; this extension does not.
*/
public final class FormLoginExtension implements FlashExtension {
public static final String LOGIN = "/auth/form/login";
private final PasswordStore store;
private PasswordEncoder encoder = PasswordEncoder.pbkdf2();
private String unknownAccountHash;
public FormLoginExtension(PasswordStore store) {
this.store = store;
}
public FormLoginExtension passwordEncoder(PasswordEncoder encoder) {
this.encoder = encoder;
return this;
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
// Checked against when no account matches, so an unknown username costs what a wrong password does.
unknownAccountHash = encoder.encode("unknown-account");
app.post(LOGIN, (req, res) -> {
String body = new String(req.body().bytes(), StandardCharsets.UTF_8);
String username = field(body, "username");
String password = field(body, "password");
if (username == null || password == null) throw HttpException.badRequest("username and password are required");
PasswordStore.Account account = store.find(username);
boolean matches = encoder.matches(password, account == null ? unknownAccountHash : account.passwordHash());
if (account == null || !matches) throw HttpException.unauthorized();
ctx.require(SecurityExtension.class).signIn(req, res, account.principal());
String redirect = req.query("redirect");
boolean local = redirect != null && redirect.startsWith("/") && !redirect.startsWith("//") && !redirect.startsWith("/\\");
res.status(303).header("Location", local ? redirect : "/");
return null;
});
ctx.onReady(() -> ctx.require(SecurityExtension.class)
.loginMethod(new LoginMethod("form", "Password", LOGIN, LoginMethod.Kind.FORM)));
}
private static String field(String body, String name) {
for (String pair : body.split("&")) {
int eq = pair.indexOf('=');
if (eq == name.length() && pair.startsWith(name)) return URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
}
return null;
}
}

Some files were not shown because too many files have changed in this diff Show More