Compare commits

13 Commits
Author SHA1 Message Date
Relism 7f225e0faf Merge pull request 'refactor(ext-oidc): replace auth modules with security extensions' (#17) from feature/ext-auth/split-oidc-into-auth-core into master
Publish Maven packages / publish (push) Successful in 2m20s
Reviewed-on: #17
2026-09-16 16:00:15 +00:00
Zakaria El Orche 86fafdb3d8 Merge remote-tracking branch 'origin/master' into feature/ext-auth/split-oidc-into-auth-core
# Conflicts:
#	AGENTS.md
#	flash-extensions/pom.xml
#	pom.xml
2026-09-16 15:54:54 +00:00
Zakaria El Orche e0795299fc refactor(ext-oidc): replace auth modules with security extensions 2026-09-16 15:54:19 +00:00
Zakaria El Orche 017c2443f4 feat(testing): support reusable request customizers 2026-09-16 15:54:10 +00:00
Zakaria El Orche b30a4af1d6 feat(core): add request helpers for security flows 2026-09-16 15:54:10 +00:00
Zakaria El Orche 096098b33c fix(ext-openapi): allow contributors to enrich default responses 2026-09-16 15:54:10 +00:00
Zakaria El Orche 829b9bf348 feat(ext-mcp): let applications put middleware on the MCP route, and document the auth split
McpExtension built its middleware chain entirely internally, so a consumer had no
way to add rate limiting, audit logging or tracing to /mcp — routine on every other
Flash route. McpConfig.middleware(...) appends to the chain after the transport
guards and after whatever McpSecurity resolved to, so it composes with OAuth2
protection instead of replacing it, and never satisfies REQUIRED.

Docs: flash-ext-auth-core and flash-ext-auth-oidc both get a docs/ directory —
oidc had none at all, and its module README documented types that no longer exist.
Includes a migration table from flash-ext-oidc.
2026-09-10 19:12:44 +00:00
Zakaria El Orche 9d39e24ccb refactor(ext-auth): generic sessions, shared annotation wiring, rename to flash-ext-auth-oidc
AuthMiddleware.install(ctx, config, source) now owns the annotation processor and
the flash.auth.policy key, so a second credential source gets annotation-driven
authorization without copying the wiring. The key is public: an extension that
contributes middleware can order itself around authentication.

OidcSession becomes Session in auth-core, carrying claims, an expiry and an opaque
attribute map. OpenID Connect keeps its access, id and refresh tokens in that map
under its own keys, so renewal stays its business and core has no OAuth2 vocabulary
in it. isAccessTokenExpired() becomes isExpired(), with the 30s eager-renewal
window it always had and now a test for it.

flash-ext-oidc is renamed flash-ext-auth-oidc, matching cache-core/cache-caffeine
and data-core/data-hibernate.
2026-09-10 19:06:15 +00:00
Zakaria El Orche c5be6ac7b8 refactor(ext-auth): extract flash-ext-auth-core out of flash-ext-oidc
flash-ext-oidc has always held two things: the OpenID Connect protocol, and a
session/claims/authorization layer that is generic and was only ever fed by one
source. This splits them along Flash's own <domain>-core convention, the same
shape cache-core, data-core and view-core already use.

flash-ext-auth-core gets what never referenced the protocol — @Authenticated,
@RolesAllowed, @ScopesAllowed, ClaimsHolder, the claim matching, and the policy
compiled from annotations — under generic names: OidcUser is Claims, since it
never was more than a typed view over a claims map, and OidcAuthPolicy is
AuthPolicy. It was package-private while being the parameter type of a public
method, so the move also fixes that.

The new seam is CredentialSource: it resolves a request's claims, or rejects the
request the way its protocol says to. AuthMiddleware publishes the result and
matches roles and scopes against it. ClaimsHolder's writers stay package-private
— an implementation produces claims and core publishes them, so nothing outside
this module can put claims on a request that did not carry them.

flash-ext-oidc keeps discovery, JWKS, PKCE, the token endpoint, the login and
callback routes and the OpenAPI oauth2 contributor, and now registers
OidcCredentialSource. flash-ext-mcp still keys McpSecurity on finding that type
and not on AuthMiddleware: REQUIRED has to keep meaning "a real authorization
server is protecting this endpoint", not "something authenticates here".

The middleware key moves with the mechanism: flash.oidc.policy -> flash.auth.policy.

Breaking for consumers: imports move to dev.relism.flash.ext.auth, OidcMiddleware
becomes AuthMiddleware, ClaimsHolder.user()/get() become current()/map().
2026-09-10 19:00:39 +00:00
Zakaria El Orche ea00182c7c test(ext-oidc): characterise claim matching before the auth-core split
Pins the current behaviour of the role/scope matching that is about to move out
of OidcMiddleware: delimiter set for string claims, whole-entry comparison for
list claims, trimming, empty-requirement semantics under ALL vs ANY, and how a
claim path that walks into a non-map resolves. None of it is OIDC-specific and
none of it was covered directly.
2026-09-10 18:48:10 +00:00
Zakaria El OrcheandClaude Sonnet 5 4feabc45d5 fix(ext-view): clear cross-engine error, propagate globals across JteExtension builder chain
- BaseViewExtension: wrap the ViewRuntimeBridge provider collision with a message
  naming the real constraint (one view engine per FlashApp) instead of the generic
  "duplicate provider" error.
- BaseViewExtension/JteExtension: carry registered globals across JteExtension's
  immutable settings builders (templateRoot/serveStatics/staticPrefix/withStaticCors/
  staticCors). addGlobal() called before any of those used to be silently dropped,
  since each builder method returned a fresh instance with an empty globals list.
- Correct flash-ext-view-jte docs (README, architecture.md, model-and-globals.md):
  globals merge flat with one typed @param per key, not under a global.* namespace
  like Thymeleaf — the docs previously claimed the same reserved namespace for both
  engines, which doesn't match JteRuntime's actual merge behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 13:31:21 +00:00
Zakaria El Orche 9de2800a83 Merge branch 'feature/ext-cache/core-and-caffeine' into feature/extensions/validation-scheduler-cache
# Conflicts:
#	AGENTS.md
#	flash-extensions/pom.xml
#	pom.xml
2026-09-09 12:37:52 +00:00
Zakaria El Orche dbf057f493 Merge branch 'feature/ext-scheduler/cron-and-interval-jobs' into feature/extensions/validation-scheduler-cache
# Conflicts:
#	AGENTS.md
#	flash-extensions/pom.xml
#	pom.xml
2026-09-09 12:37:26 +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-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/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-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-security-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/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/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-openapi/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" 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-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-jackson` | Jackson JSON integration |
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI | | `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-security-core` | Security: authentication chain, annotations, sessions, OpenAPI |
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-oidc | | `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-core` | Minimal shared SSR runtime primitives |
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension | | `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf 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: See extension-specific READMEs for full details:
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md) - [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/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-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/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) - [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
@@ -36,7 +36,7 @@ FlashApp.create(8080)
// With custom resolvers // With custom resolvers
LimiterConfig conf = new LimiterConfig() LimiterConfig conf = new LimiterConfig()
.registerResolver("auth_user", req -> .registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous"); SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous");
FlashApp.create(8080) FlashApp.create(8080)
.install(new LimiterExtension(conf)) .install(new LimiterExtension(conf))
@@ -35,11 +35,11 @@ conf.registerResolver("ip", req -> {
LimiterConfig conf = new LimiterConfig(); LimiterConfig conf = new LimiterConfig();
``` ```
### By authenticated user (OIDC / ClaimsHolder) ### By authenticated user
```java ```java
conf.registerResolver("auth_user", req -> 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 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 ```java
conf.registerResolver("auth_user", req -> conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anon"); SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anon");
``` ```
```java ```java
@@ -11,7 +11,7 @@ import dev.relism.flash.models.Request;
* *
* <pre>{@code * <pre>{@code
* conf.registerResolver("ip", req -> req.header("X-Forwarded-For")); * 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> * }</pre>
*/ */
@FunctionalInterface @FunctionalInterface
@@ -21,8 +21,8 @@ import java.util.Map;
* <pre>{@code * <pre>{@code
* LimiterConfig conf = new LimiterConfig() * LimiterConfig conf = new LimiterConfig()
* .registerResolver("auth_user", req -> { * .registerResolver("auth_user", req -> {
* // custom logic — e.g. extract sub from ClaimsHolder * // custom logic — e.g. key by the authenticated caller
* return ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous"; * return SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous";
* }); * });
* *
* app.install(new LimiterExtension(conf)); * app.install(new LimiterExtension(conf));
@@ -43,7 +43,7 @@ import java.util.Map;
* <h3>Lambda routes (via Guard)</h3> * <h3>Lambda routes (via Guard)</h3>
* <pre>{@code * <pre>{@code
* app.install(new LimiterExtension( * 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: * // inside a FlashContext.onReady(...) callback:
* Guard guard = ctx.require(Guard.class); * 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 `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 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 declared as plain classes and discovered at boot, optional OAuth2 protection built on
`flash-ext-oidc`. `flash-ext-security-core`.
## Quick Start ## Quick Start
@@ -44,7 +44,7 @@ public class GetWeatherTool extends McpTool {
`tools-resources-prompts.md`. `tools-resources-prompts.md`.
- **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md` - **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
for exactly what that means and why. 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 - **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. `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 - [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts
- [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation - [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation
- [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707 - [`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` - [`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 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. 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 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 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. 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 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 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 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 `flash-ext-jackson` isn't installed. That would be purely additive on top of the
already used for `McpSecurity.AUTO`. That would be purely additive on top of the
`JsonGenerator`-based envelope/content writing described above, not a replacement for it — 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 fixed-shape protocol plumbing has no reason to ever go through databinding, regardless of what
convenience layer gets added around it. 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 # Security
Provider-specific setup steps (not generic OAuth2 mechanics) live in separate cookbooks — The MCP endpoint is secured by [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md):
[`keycloak.md`](keycloak.md) for Keycloak: enabling Dynamic Client Registration, why the RFC 8707 whatever mechanisms the application registers — OAuth2 bearer tokens, API keys, custom ones —
audience mapper needs to go on the built-in `basic` scope instead of a custom one, and the exact authenticate `/mcp` exactly as they authenticate every other route.
Allowed Client Scopes configuration `scopes_supported` needs to actually work.
## `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 ## OAuth2 protected resource
installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExtension.routes()`:
| Policy | `flash-ext-oidc` installed | `flash-ext-oidc` absent | 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:
| `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 |
Use `REQUIRED` for anything you intend to run in production reachable over the network — it - `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata: the `resource` (derived per
turns "someone forgot to wire up OAuth2" into a startup crash instead of a silently open request from `X-Forwarded-Proto`/`-Host` or `Host`), every issuer as `authorization_servers`, and
endpoint. `AUTO` is meant for local development, where spinning up a real identity provider is `scopes_supported` when `McpConfig.scopesSupported(...)` is set;
friction you don't want yet. - 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 `McpConfig.requireTokenAudience(false)` drops that last check for an authorization server that cannot
`flash-ext-mcp` don't get `flash-ext-oidc` pulled in automatically unless they add it themselves. mint a resource audience at all — Keycloak ignores RFC 8707's `resource` parameter, so a deployment that
Within `flash-ext-mcp` itself, `flash-ext-oidc`'s classes are on the compile/test classpath as cannot add the mapper has no other way in. Every token a registered issuer signs is then accepted on the
normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in endpoint, and the boot logs say so.
source.
That reference is isolated in its own class, `McpOidcIntegration`, invoked only from inside a ## Tool policies
`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.
## OAuth2 resolution details — zero-config by default The core annotations work on tools as on handlers, checked per `tools/call` against the caller the route
authenticated:
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`:
```java ```java
@Tool(name = "delete_route", description = "Delete a route") @Tool(name = "approve", description = "Approves a pending proposal")
@RolesAllowed("admin") @RolesAllowed(value = "REVIEWER", on = {"project", "locale"}) // read from the tool's arguments
public class DeleteRouteTool extends McpTool { public class ApproveTool extends McpTool { }
@Override public ToolResponse call(ToolArguments args) { ... }
}
``` ```
This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`, A denial is a tool result with `isError: true` — the call reached the server, the tool did not run.
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.
Roles are read via `OidcUser#hasRole` against `McpConfig.rolesClaimPath(...)` (default `McpConfig.middleware(...)` runs after authentication, for rate limiting, auditing or tracing.
`"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.
+17 -2
View File
@@ -19,8 +19,7 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId> <artifactId>flash-ext-security-core</artifactId>
<optional>true</optional>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.fasterxml.jackson.core</groupId> <groupId>com.fasterxml.jackson.core</groupId>
@@ -43,6 +42,22 @@
<artifactId>flash-testing</artifactId> <artifactId>flash-testing</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </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> </dependencies>
</project> </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; package dev.relism.flash.ext.mcp;
import dev.relism.flash.routing.Middleware;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -24,10 +26,10 @@ public final class McpConfig {
private final String rootPath; private final String rootPath;
private final String toolsPackage; private final String toolsPackage;
private final McpSecurity security; private final McpSecurity security;
private final String resourceIdentifier; private final boolean requireTokenAudience;
private final String authorizationServerIssuer;
private final List<String> allowedOrigins; private final List<String> allowedOrigins;
private final List<String> scopesSupported; private final List<String> scopesSupported;
private final List<Middleware> middleware;
private McpConfig(Builder b) { private McpConfig(Builder b) {
this.name = b.name; this.name = b.name;
@@ -36,10 +38,10 @@ public final class McpConfig {
this.rootPath = b.rootPath; this.rootPath = b.rootPath;
this.toolsPackage = b.toolsPackage; this.toolsPackage = b.toolsPackage;
this.security = b.security; this.security = b.security;
this.resourceIdentifier = b.resourceIdentifier; this.requireTokenAudience = b.requireTokenAudience;
this.authorizationServerIssuer = b.authorizationServerIssuer;
this.allowedOrigins = List.copyOf(b.allowedOrigins); this.allowedOrigins = List.copyOf(b.allowedOrigins);
this.scopesSupported = List.copyOf(b.scopesSupported); this.scopesSupported = List.copyOf(b.scopesSupported);
this.middleware = List.copyOf(b.middleware);
} }
String name() { return name; } String name() { return name; }
@@ -48,10 +50,10 @@ public final class McpConfig {
String rootPath() { return rootPath; } String rootPath() { return rootPath; }
String toolsPackage() { return toolsPackage; } String toolsPackage() { return toolsPackage; }
McpSecurity security() { return security; } McpSecurity security() { return security; }
String resourceIdentifier() { return resourceIdentifier; } boolean requireTokenAudience() { return requireTokenAudience; }
String authorizationServerIssuer() { return authorizationServerIssuer; }
List<String> allowedOrigins() { return allowedOrigins; } List<String> allowedOrigins() { return allowedOrigins; }
List<String> scopesSupported() { return scopesSupported; } List<String> scopesSupported() { return scopesSupported; }
List<Middleware> middleware() { return middleware; }
public static Builder builder(String name) { return new Builder(name); } public static Builder builder(String name) { return new Builder(name); }
@@ -61,11 +63,11 @@ public final class McpConfig {
private String instructions; private String instructions;
private String rootPath = "/mcp"; private String rootPath = "/mcp";
private String toolsPackage; private String toolsPackage;
private McpSecurity security = McpSecurity.AUTO; private McpSecurity security = McpSecurity.REQUIRED;
private String resourceIdentifier; private boolean requireTokenAudience = true;
private String authorizationServerIssuer;
private final List<String> allowedOrigins = new ArrayList<>(); private final List<String> allowedOrigins = new ArrayList<>();
private final List<String> scopesSupported = new ArrayList<>(); private final List<String> scopesSupported = new ArrayList<>();
private final List<Middleware> middleware = new ArrayList<>();
private Builder(String name) { private Builder(String name) {
if (name == null || name.isBlank()) 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. */ /** Package scanned for {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes. Required. */
public Builder toolsPackage(String toolsPackage) { this.toolsPackage = toolsPackage; return this; } 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; } public Builder security(McpSecurity security) { this.security = security; return this; }
/** /**
* Canonical URI of this MCP endpoint, used for RFC 8707 audience binding: tokens whose * Whether a bearer token must name this endpoint in its {@code aud} (RFC 8707), as the MCP
* {@code aud} claim does not include this value are rejected. Optional — when * authorization spec requires. Default {@code true}. Turn it off for an authorization server
* {@code flash-ext-oidc} is installed, this is auto-derived per request from the * that cannot mint a resource audience — every token a registered issuer signs is then
* forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own * accepted on the endpoint, and a warning is logged at boot.
* 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}.
*/ */
public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; } public Builder requireTokenAudience(boolean require) { this.requireTokenAudience = require; 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; }
/** /**
* Origins allowed to call the MCP endpoint (DNS-rebinding protection, per the Streamable * 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; } public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; }
/** /** Published as {@code scopes_supported} in the RFC 9728 metadata, so OAuth clients request them. */
* 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.
*/
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; } 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() { public McpConfig build() {
if (toolsPackage == null || toolsPackage.isBlank()) if (toolsPackage == null || toolsPackage.isBlank())
throw new IllegalStateException( throw new IllegalStateException(
@@ -3,6 +3,8 @@ package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import dev.relism.flash.http.ContentType; 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.Request;
import dev.relism.flash.models.Response; 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, * 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 * 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 * 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 * 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 * route-wide 401/403 already happened earlier, in the security middleware, before this
* OidcMiddleware}/audience-guard middleware chain, before this dispatcher ever runs. * dispatcher ever runs.
*/ */
final class McpDispatcher { final class McpDispatcher {
@@ -136,12 +138,14 @@ final class McpDispatcher {
if (tool == null) if (tool == null)
throw McpProtocolException.invalidParams("Unknown tool: " + name); 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")); 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 { try {
result = tool.instance().call(args); result = tool.instance().call(args);
} catch (Exception e) { } catch (Exception e) {
@@ -1,5 +1,10 @@
package dev.relism.flash.ext.mcp; 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.FlashContext;
import dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar; import dev.relism.flash.extension.FlashRegistrar;
@@ -9,38 +14,25 @@ import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects;
/** /**
* MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single * MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single {@code POST}
* {@code POST} JSON-RPC endpoint, stateless in this revision (no session, no SSE stream; see * JSON-RPC endpoint, stateless in this revision (see {@code docs/transport.md}) — dispatching to
* {@code docs/transport.md}) — dispatch precompiled at boot from classes annotated with * {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes under
* {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} under
* {@link McpConfig#toolsPackage(String)}. * {@link McpConfig#toolsPackage(String)}.
* *
* <pre>{@code * <pre>{@code
* // Standalone, no OAuth2 * app.install(new SecurityExtension())
* FlashApp.create(8080) * .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)))
* .install(new McpExtension(McpConfig.builder("my-mcp-server") * .install(new McpExtension(McpConfig.builder("my-server").toolsPackage("com.example.tools").build()));
* .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();
* }</pre> * }</pre>
* *
* <p>One server per {@code McpExtension} instance — install multiple instances (distinct * <p>Every call is authenticated by the application's security chain — OAuth2 bearer tokens, API
* {@code rootPath}, distinct {@code toolsPackage}) for multiple MCP servers on one app, * keys, anything registered. When an OAuth2 issuer is among its schemes, the endpoint is also an
* mirroring the {@code OidcExtension} multi-tenant pattern. See {@code docs/security.md} for * OAuth2 protected resource: RFC 9728 metadata, a {@code resource_metadata} challenge, and RFC 8707
* the full OAuth2 resolution rules. * audience binding for audience-bound tokens. Tool annotations are enforced per call, with
* {@code @RolesAllowed(on = ...)} reading tool arguments.
*/ */
@Slf4j @Slf4j
public class McpExtension implements FlashExtension { public class McpExtension implements FlashExtension {
@@ -53,68 +45,54 @@ public class McpExtension implements FlashExtension {
@Override @Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) { 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) { List<Middleware> chain = new ArrayList<>(List.of(
// Resolved before scanning so McpRegistry knows, per tool, whether @RolesAllowed/ McpTransportGuards.httpExceptionGuard(), McpTransportGuards.originGuard(config.allowedOrigins())));
// @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration if (security != null) protect(app, security, chain);
// (see McpOidcIntegration#compileToolPolicy) — must run first, not after. chain.addAll(config.middleware());
McpOidcIntegration.Resolved secured = resolveSecurity(ctx); app.post(config.rootPath(), (req, res) -> {
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null, dispatcher.handle(req, res);
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());
return null; return null;
} }, chain.toArray(Middleware[]::new));
/**
* 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());
}); });
} }
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 * <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 * 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 * (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. * 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 com.fasterxml.jackson.core.JsonGenerator;
import dev.relism.flash.exceptions.InitializationException; 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 dev.relism.flash.extension.FlashContext;
import java.io.IOException; import java.io.IOException;
@@ -25,7 +27,7 @@ final class McpRegistry {
private static final String EMPTY_ARRAY = "[]"; private static final String EMPTY_ARRAY = "[]";
/** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */ /** {@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 RegisteredResource(String uri, McpResource instance) {}
record RegisteredPrompt(String name, McpPrompt instance) {} record RegisteredPrompt(String name, McpPrompt instance) {}
@@ -39,15 +41,8 @@ final class McpRegistry {
private McpRegistry() {} private McpRegistry() {}
/** /** @param security {@code null} for a server running with {@link McpSecurity#NONE} */
* @param oidcActive whether this MCP server's route is actually OAuth2-protected right static McpRegistry scan(String packageName, FlashContext ctx, SecurityExtension security) {
* 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) {
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName); McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
McpRegistry registry = new McpRegistry(); McpRegistry registry = new McpRegistry();
@@ -55,7 +50,9 @@ final class McpRegistry {
Tool ann = cls.getAnnotation(Tool.class); Tool ann = cls.getAnnotation(Tool.class);
McpTool instance = instantiate(cls); McpTool instance = instantiate(cls);
instance.bind(ctx); 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) if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null)
throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\""); throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\"");
} }
@@ -173,24 +170,6 @@ final class McpRegistry {
gen.writeEndArray(); 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) { private static <T> T instantiate(Class<T> cls) {
try { try {
Constructor<T> ctor = cls.getDeclaredConstructor(); Constructor<T> ctor = cls.getDeclaredConstructor();
@@ -2,18 +2,18 @@ package dev.relism.flash.ext.mcp;
import java.util.List; 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 { final class McpResourceMetadata {
private McpResourceMetadata() {} private McpResourceMetadata() {}
/** {@code scopesSupported} is optional per RFC 9728 — omitted from the document if empty. */ /** {@code scopesSupported} is optional — omitted when empty. */
static String build(String resourceIdentifier, String authorizationServerIssuer, List<String> scopesSupported) { static String build(String resource, List<String> authorizationServers, List<String> scopesSupported) {
return McpJson.buildString(gen -> { return McpJson.buildString(gen -> {
gen.writeStartObject(); gen.writeStartObject();
gen.writeStringField("resource", resourceIdentifier); gen.writeStringField("resource", resource);
gen.writeArrayFieldStart("authorization_servers"); gen.writeArrayFieldStart("authorization_servers");
gen.writeString(authorizationServerIssuer); for (String issuer : authorizationServers) gen.writeString(issuer);
gen.writeEndArray(); gen.writeEndArray();
if (!scopesSupported.isEmpty()) { if (!scopesSupported.isEmpty()) {
gen.writeArrayFieldStart("scopes_supported"); gen.writeArrayFieldStart("scopes_supported");
@@ -1,17 +1,11 @@
package dev.relism.flash.ext.mcp; package dev.relism.flash.ext.mcp;
/** /** Whether the MCP endpoint requires an authenticated caller. */
* OAuth2 requirement policy for the MCP endpoint, resolved against whether
* {@code flash-ext-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
*/
public enum McpSecurity { 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, REQUIRED,
/** Protect the endpoint if {@code flash-ext-oidc} is installed; otherwise run unprotected and log a warning. */ /** A public endpoint. Tools declaring security annotations fail the boot. */
AUTO,
/** Never protect the endpoint, even if {@code flash-ext-oidc} is installed elsewhere in the app. */
NONE NONE
} }
@@ -19,7 +19,7 @@ final class McpTransportGuards {
* allowed through — only a <em>present but disallowed</em> value is rejected. * 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 * <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) { static Middleware originGuard(List<String> allowedOrigins) {
if (allowedOrigins.isEmpty()) { if (allowedOrigins.isEmpty()) {
@@ -38,7 +38,7 @@ final class McpTransportGuards {
/** /**
* Safety net around the whole MCP route: translates {@link HttpException} (thrown by * 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 * 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 * generic 500 for every exception type unless the app owner overrides it (see
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint * {@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 @Test
void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception { 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.hasTools());
assertTrue(registry.hasResources()); assertTrue(registry.hasResources());
@@ -47,7 +47,7 @@ class McpRegistryTest {
@Test @Test
void scan_emptyPackage_throwsInitializationException() { void scan_emptyPackage_throwsInitializationException() {
assertThrows(InitializationException.class, 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) { 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.Tool;
import dev.relism.flash.ext.mcp.ToolArguments; import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse; 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") @Tool(name = "admin_only", description = "Only callable with the admin role")
@RolesAllowed("admin") @RolesAllowed(value = "admin", on = "project")
public class AdminOnlyTool extends McpTool { public class AdminOnlyTool extends McpTool {
@Override @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.Tool;
import dev.relism.flash.ext.mcp.ToolArguments; import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse; 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") @Tool(name = "write_only", description = "Only callable with the write scope")
@ScopesAllowed("write") @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** - contributor collisions use **last-wins**
- manual `@APIResponse` description always wins over contributors for the same status - manual `@APIResponse` description always wins over contributors for the same status
## OIDC interop ## Security interop
When `flash-ext-oidc` is installed, OpenAPI integrates automatically: 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 scheme under `components.securitySchemes` `security` alternatives with automatic `401` and — for roles or scopes — `403` responses.
- per-operation `security`
- auto responses (class-based handlers):
- `401 Authentication required`
- `403` role/scope required messages when applicable
Manual `@APIResponse` for the same status code always wins. Manual `@APIResponse` for the same status code always wins.
@@ -176,7 +176,8 @@ public final class OpenApiBuilder {
} }
if (responseByCode.isEmpty()) { 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); 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) @Schema(name = "UserDTO", title = "User model", description = "DTO", deprecated = true)
@JsonIgnoreProperties({"ignoredByType"}) @JsonIgnoreProperties({"ignoredByType"})
static class UserDto { static class UserDto {
@@ -341,6 +350,34 @@ class OpenApiBuilderTest {
assertEquals("new", header.get("description")); 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) { 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> paths = cast(spec.get("paths"));
Map<String, Object> pathItem = cast(paths.get(path)); 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 * 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) { private static List<String> buildMiddlewareNames(List<Class<? extends Middleware>> chain) {
List<String> names = new ArrayList<>(chain.size()); 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> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-oidc</artifactId> <artifactId>flash-ext-security-core</artifactId>
<dependencies> <dependencies>
<dependency> <dependency>
@@ -22,22 +22,14 @@
<artifactId>flash-ext-openapi</artifactId> <artifactId>flash-ext-openapi</artifactId>
<optional>true</optional> <optional>true</optional>
</dependency> </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> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>
</dependency> </dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </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