diff --git a/.idea/encodings.xml b/.idea/encodings.xml index aa0097f..278fb63 100644 --- a/.idea/encodings.xml +++ b/.idea/encodings.xml @@ -17,8 +17,8 @@ - - + + diff --git a/README.md b/README.md index 10ac1e4..bb94243 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,12 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res | `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses | | `flash-extensions/flash-ext-jackson` | Jackson JSON integration | | `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI | -| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow | -| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-oidc | +| `flash-extensions/flash-ext-security-core` | Security: authentication chain, annotations, sessions, OpenAPI | +| `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE | +| `flash-extensions/flash-ext-security-apikey` | API keys | +| `flash-extensions/flash-ext-security-form` | Password sign-in | +| `flash-extensions/flash-ext-security-test` | Test identities, fake OpenID Provider | +| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, secured by flash-ext-security-core | | `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives | | `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension | | `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension | @@ -146,7 +150,11 @@ FlashApp.create(8080) See extension-specific READMEs for full details: - [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md) - [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md) -- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md) +- [`flash-ext-security-core`](flash-extensions/flash-ext-security-core/docs/README.md) +- [`flash-ext-security-oidc`](flash-extensions/flash-ext-security-oidc/docs/README.md) +- [`flash-ext-security-apikey`](flash-extensions/flash-ext-security-apikey/docs/README.md) +- [`flash-ext-security-form`](flash-extensions/flash-ext-security-form/docs/README.md) +- [`flash-ext-security-test`](flash-extensions/flash-ext-security-test/docs/README.md) - [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md) - [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md) - [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md) diff --git a/flash-extensions/flash-ext-limiter/docs/README.md b/flash-extensions/flash-ext-limiter/docs/README.md index 098b0d8..8f1568a 100644 --- a/flash-extensions/flash-ext-limiter/docs/README.md +++ b/flash-extensions/flash-ext-limiter/docs/README.md @@ -36,7 +36,7 @@ FlashApp.create(8080) // With custom resolvers LimiterConfig conf = new LimiterConfig() .registerResolver("auth_user", req -> - ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous"); + SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous"); FlashApp.create(8080) .install(new LimiterExtension(conf)) diff --git a/flash-extensions/flash-ext-limiter/docs/key-resolvers.md b/flash-extensions/flash-ext-limiter/docs/key-resolvers.md index 1b39d34..137d2cf 100644 --- a/flash-extensions/flash-ext-limiter/docs/key-resolvers.md +++ b/flash-extensions/flash-ext-limiter/docs/key-resolvers.md @@ -35,11 +35,11 @@ conf.registerResolver("ip", req -> { LimiterConfig conf = new LimiterConfig(); ``` -### By authenticated user (OIDC / ClaimsHolder) +### By authenticated user ```java conf.registerResolver("auth_user", req -> - ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous"); + SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous"); ``` Requests from unauthenticated users share the `"anonymous"` bucket. If you want @@ -88,7 +88,7 @@ returns the same key for the same user regardless of endpoint; the limit is set ```java conf.registerResolver("auth_user", req -> - ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anon"); + SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anon"); ``` ```java diff --git a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java index 7fd678a..f8848e9 100644 --- a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java +++ b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java @@ -11,7 +11,7 @@ import dev.relism.flash.models.Request; * *
{@code
  * conf.registerResolver("ip",       req -> req.header("X-Forwarded-For"));
- * conf.registerResolver("auth_user", req -> ClaimsHolder.user().sub());
+ * conf.registerResolver("auth_user", req -> SecurityIdentity.current().principal().name());
  * }
*/ @FunctionalInterface diff --git a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java index 607db07..b6b93df 100644 --- a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java +++ b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java @@ -21,8 +21,8 @@ import java.util.Map; *
{@code
  * LimiterConfig conf = new LimiterConfig()
  *     .registerResolver("auth_user", req -> {
- *         // custom logic — e.g. extract sub from ClaimsHolder
- *         return ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous";
+ *         // custom logic — e.g. key by the authenticated caller
+ *         return SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous";
  *     });
  *
  * app.install(new LimiterExtension(conf));
diff --git a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java
index 9f09e85..7e5c5b2 100644
--- a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java
+++ b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java
@@ -43,7 +43,7 @@ import java.util.Map;
  * 

Lambda routes (via Guard)

*
{@code
  * app.install(new LimiterExtension(
- *     new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
+ *     new LimiterConfig().registerResolver("auth_user", req -> SecurityIdentity.current().principal().name())));
  *
  * // inside a FlashContext.onReady(...) callback:
  * Guard guard = ctx.require(Guard.class);
diff --git a/flash-extensions/flash-ext-mcp/docs/README.md b/flash-extensions/flash-ext-mcp/docs/README.md
index 643ce0b..5d9ee7f 100644
--- a/flash-extensions/flash-ext-mcp/docs/README.md
+++ b/flash-extensions/flash-ext-mcp/docs/README.md
@@ -3,7 +3,7 @@
 `flash-ext-mcp` turns a Flash5 app into an [MCP](https://modelcontextprotocol.io) (Model Context
 Protocol) server: JSON-RPC 2.0 over the Streamable HTTP transport, tools/resources/prompts
 declared as plain classes and discovered at boot, optional OAuth2 protection built on
-`flash-ext-oidc`.
+`flash-ext-security-core`.
 
 ## Quick Start
 
@@ -44,7 +44,7 @@ public class GetWeatherTool extends McpTool {
   `tools-resources-prompts.md`.
 - **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
   for exactly what that means and why.
-- **Security**: optional, policy-driven OAuth2 via `flash-ext-oidc` — see `security.md`.
+- **Security**: authenticated by `flash-ext-security-core`, an OAuth2 protected resource when OIDC is installed — see `security.md`.
 - **JSON**: this extension owns its JSON handling independently of `flash-ext-jackson` — see
   `jackson-interop.md` for why, and how a future opt-in reuse could work.
 
@@ -53,6 +53,4 @@ public class GetWeatherTool extends McpTool {
 - [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts
 - [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation
 - [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707
-- [`keycloak.md`](keycloak.md) — Keycloak-specific setup cookbook: Dynamic Client Registration,
-  the RFC 8707 audience mapper gotcha, and how to verify/debug it
 - [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson`
diff --git a/flash-extensions/flash-ext-mcp/docs/jackson-interop.md b/flash-extensions/flash-ext-mcp/docs/jackson-interop.md
index dddee59..19873c4 100644
--- a/flash-extensions/flash-ext-mcp/docs/jackson-interop.md
+++ b/flash-extensions/flash-ext-mcp/docs/jackson-interop.md
@@ -24,7 +24,7 @@ see `tools-resources-prompts.md` — and the fixed `TextContent`/`TextResourceCo
 as a `JsonNode` tree, not as a databound class, for the same reason — a JSON-RPC tool call's
 arguments aren't a DTO with getters/setters, they're a dynamic, per-tool-defined bag of values.
 
-This mirrors how `flash-ext-oidc` already handles its own internal JSON needs (`json-smart` for
+This mirrors how `flash-ext-security-oidc` handles its own JSON needs (Nimbus's parser for
 token-endpoint responses) independently of `flash-ext-jackson` — extensions with protocol-level
 JSON needs that are shaped by a spec, not by user code, own that JSON handling themselves rather
 than routing it through the app's general-purpose JSON extension.
@@ -44,8 +44,7 @@ Nothing here rules out a later, additive convenience layer: `McpExtension.routes
 that shared mapper as the backing for an escape hatch such as `ToolArguments.as(Class)` or
 for a tool that wants to `ToolResponse.success(someRecord)` and have it serialized with the
 app's own conventions — falling back to a locally-constructed default `ObjectMapper` when
-`flash-ext-jackson` isn't installed, the same "prefer shared, degrade to sane default" shape
-already used for `McpSecurity.AUTO`. That would be purely additive on top of the
+`flash-ext-jackson` isn't installed. That would be purely additive on top of the
 `JsonGenerator`-based envelope/content writing described above, not a replacement for it — the
 fixed-shape protocol plumbing has no reason to ever go through databinding, regardless of what
 convenience layer gets added around it.
diff --git a/flash-extensions/flash-ext-mcp/docs/keycloak.md b/flash-extensions/flash-ext-mcp/docs/keycloak.md
deleted file mode 100644
index 6326541..0000000
--- a/flash-extensions/flash-ext-mcp/docs/keycloak.md
+++ /dev/null
@@ -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= does not include expected
-resource identifier "" — ...
-```
-
-`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`.
diff --git a/flash-extensions/flash-ext-mcp/docs/security.md b/flash-extensions/flash-ext-mcp/docs/security.md
index 43e3903..917fc94 100644
--- a/flash-extensions/flash-ext-mcp/docs/security.md
+++ b/flash-extensions/flash-ext-mcp/docs/security.md
@@ -1,170 +1,47 @@
 # Security
 
-Provider-specific setup steps (not generic OAuth2 mechanics) live in separate cookbooks —
-[`keycloak.md`](keycloak.md) for Keycloak: enabling Dynamic Client Registration, why the RFC 8707
-audience mapper needs to go on the built-in `basic` scope instead of a custom one, and the exact
-Allowed Client Scopes configuration `scopes_supported` needs to actually work.
+The MCP endpoint is secured by [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md):
+whatever mechanisms the application registers — OAuth2 bearer tokens, API keys, custom ones —
+authenticate `/mcp` exactly as they authenticate every other route.
 
-## `McpSecurity`
+| `McpConfig.security(...)` | |
+|---|---|
+| `REQUIRED` (default) | every call must be authenticated; boot fails without a `SecurityExtension` |
+| `NONE` | a public endpoint; a tool carrying security annotations fails the boot |
 
-`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-oidc` being
-installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExtension.routes()`:
+## OAuth2 protected resource
 
-| Policy | `flash-ext-oidc` installed | `flash-ext-oidc` absent |
-|---|---|---|
-| `REQUIRED` | protected | **boot fails** (`IllegalStateException`) |
-| `AUTO` (default) | protected | runs unprotected, logs a warning |
-| `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected |
+When a registered mechanism publishes an OAuth2 issuer — `flash-ext-security-oidc` does — the endpoint
+behaves as the MCP authorization spec requires, with nothing to configure:
 
-Use `REQUIRED` for anything you intend to run in production reachable over the network — it
-turns "someone forgot to wire up OAuth2" into a startup crash instead of a silently open
-endpoint. `AUTO` is meant for local development, where spinning up a real identity provider is
-friction you don't want yet.
+- `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata: the `resource` (derived per
+  request from `X-Forwarded-Proto`/`-Host` or `Host`), every issuer as `authorization_servers`, and
+  `scopes_supported` when `McpConfig.scopesSupported(...)` is set;
+- an anonymous call gets `401` with `WWW-Authenticate: Bearer resource_metadata="…"`;
+- a token whose `aud` does not include the resource is `403` (RFC 8707) and logged at `WARN`. Credentials
+  that are not audience-bound, such as API keys, are unaffected.
 
-## Why `flash-ext-oidc` is an *optional* Maven dependency, concretely
+For Keycloak, the audience comes from an *Audience* protocol mapper whose included custom audience is
+the resource URL, attached to a client scope every MCP client receives (the built-in `basic` scope is the
+one that needs no client cooperation). Clients that register dynamically need Keycloak's anonymous
+client registration policies relaxed for the trusted hosts.
 
-Maven's `true` only affects **transitive** propagation: consumers of
-`flash-ext-mcp` don't get `flash-ext-oidc` pulled in automatically unless they add it themselves.
-Within `flash-ext-mcp` itself, `flash-ext-oidc`'s classes are on the compile/test classpath as
-normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in
-source.
+`McpConfig.requireTokenAudience(false)` drops that last check for an authorization server that cannot
+mint a resource audience at all — Keycloak ignores RFC 8707's `resource` parameter, so a deployment that
+cannot add the mapper has no other way in. Every token a registered issuer signs is then accepted on the
+endpoint, and the boot logs say so.
 
-That reference is isolated in its own class, `McpOidcIntegration`, invoked only from inside a
-`catch (NoClassDefFoundError)` block. A bare class-literal like `OidcMiddleware.class` (which
-`ctx.find(OidcMiddleware.class)` needs) forces the JVM to resolve that type the moment it's
-evaluated — if `flash-ext-oidc` is not on the *runtime* classpath at all (a genuinely
-MCP-only install, no OAuth2 anywhere in the app), the first such reference throws
-`NoClassDefFoundError`. Keeping that reference inside a separate, lazily-loaded class means
-`McpExtension` itself loads and works fine standalone; only the attempt to actually use OIDC
-fails, and only when there's something to fail. This mirrors `OidcExtension`'s own lazy bridge to
-`flash-ext-openapi` — same technique, same reason.
+## Tool policies
 
-## OAuth2 resolution details — zero-config by default
-
-When oidc is available and `security() != NONE`, `McpOidcIntegration` (an isolated,
-lazily-loaded bridge — see its javadoc) derives everything an MCP OAuth2 resource server needs
-straight from the installed `OidcMiddleware`, with no additional `McpConfig` calls required:
-
-1. The MCP route is wrapped with `flash-ext-oidc`'s own `OidcMiddleware.protect(resourceMetadataPath)`
-   — the same Bearer-token/JWKS validation path used everywhere else in Flash5, plus a
-   `resource_metadata` challenge parameter (see below). No JWT parsing or JWKS handling is
-   reimplemented here.
-2. An audience guard always runs after `protect(...)`: it reads the validated claims from
-   `ClaimsHolder` and rejects (`403`) any token whose `aud` claim does not include the resource
-   identifier — **RFC 8707 Resource Indicators / audience binding**, enforced unconditionally,
-   not opt-in. `OidcMiddleware` itself validates `aud` against its own `clientId` for ID
-   tokens, but deliberately does not enforce audience on access tokens (it varies by provider)
-   — the MCP extension adds that check on top, scoped to its own resource identifier.
-3. The resource identifier is the canonical URI of the MCP endpoint, resolved **per request** by
-   `OidcMiddleware#selfOrigin` + `rootPath` — the same scheme/host resolution `OidcExtension`
-   uses for its own redirect URIs: `X-Forwarded-Host`/`X-Forwarded-Proto` when the request came
-   through a reverse proxy, otherwise `{selfScheme()}://{Host header}`. Behind a proxy the
-   `Host` alone is the upstream address the proxy dialled, which would publish a resource
-   identifier no client can reach. `McpConfig.resourceIdentifier(...)` still overrides it
-   outright for a proxy that forwards neither header.
-4. The authorization server issuer is read from `OidcMiddleware#issuer()` unless
-   `McpConfig.authorizationServerIssuer(...)` overrides it.
-
-## RFC 9728 Protected Resource Metadata
-
-Whenever the endpoint ends up protected, `flash-ext-mcp` publishes a Protected Resource Metadata
-document at `/.well-known/oauth-protected-resource{rootPath}` — no explicit `resourceIdentifier`/
-`authorizationServerIssuer` configuration required, both are auto-derived as described above:
-
-```json
-{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/realms/myrealm"] }
-```
-
-`resource` is computed per request from the incoming request's forwarded/`Host` headers (see
-above), so the document is correct without hardcoding the server's own public URL.
-
-### `scopes_supported`
-
-Optional per RFC 9728, omitted from the document entirely unless set via
-`McpConfig.scopesSupported("openid", "profile", "email")`:
-
-```json
-{ "resource": "...", "authorization_servers": ["..."], "scopes_supported": ["openid", "profile", "email"] }
-```
-
-This is pure advertisement — token validation doesn't change based on it — but it matters in
-practice: a client that ignores it and requests no scope at all (many do — see `keycloak.md`)
-only gets back whatever the authorization server treats as always-included regardless of
-request, which for Keycloak is just its built-in `basic` scope. A client that *does* read
-`scopes_supported` and echoes it back in its authorization/token requests gets a token with the
-claims those scopes actually provide (`profile` → `preferred_username`/`name`, etc.), without
-needing every one of those claims hand-mapped onto `basic`. Set it to whatever scopes your
-`McpTool`s actually read off `ClaimsHolder`/`OidcUser` — there's no way to auto-derive this list,
-it depends entirely on what your tools do with the claims.
-
-## `WWW-Authenticate: resource_metadata` (RFC 9728 §5.1)
-
-The MCP Authorization spec **requires** a `401` to carry `resource_metadata` in
-`WWW-Authenticate`, pointing at the Protected Resource Metadata document above — this is how a
-spec-compliant client discovers the authorization server without out-of-band configuration.
-`OidcMiddleware.protect(String resourceMetadataPath)` (an overload added specifically for this)
-builds that challenge automatically:
-
-```
-WWW-Authenticate: Bearer realm="...", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
-```
-
-The plain `OidcMiddleware.protect()` (no argument), used by every other Flash5 app, is
-unaffected — this parameter is additive and MCP-specific.
-
-## Per-tool `@RolesAllowed`/`@ScopesAllowed`
-
-`McpTool` subclasses can carry `flash-ext-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
+The core annotations work on tools as on handlers, checked per `tools/call` against the caller the route
+authenticated:
 
 ```java
-@Tool(name = "delete_route", description = "Delete a route")
-@RolesAllowed("admin")
-public class DeleteRouteTool extends McpTool {
-    @Override public ToolResponse call(ToolArguments args) { ... }
-}
+@Tool(name = "approve", description = "Approves a pending proposal")
+@RolesAllowed(value = "REVIEWER", on = {"project", "locale"})   // read from the tool's arguments
+public class ApproveTool extends McpTool { … }
 ```
 
-This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
-the thing that makes these annotations work on a `RequestHandler`) — it can't: every tool shares
-one HTTP route (`POST {rootPath}`), already wrapped by whatever `McpSecurity` resolved above, so
-there is no per-tool route to attach a different middleware chain to. Instead,
-`McpOidcIntegration.compileToolPolicy` reads the annotations once at boot (`McpRegistry.scan`)
-and compiles them into a closure (`McpAuthPolicy`) that `McpDispatcher` runs *after* the
-route-wide auth has already succeeded and *before* invoking the specific tool named in the
-`tools/call` request — narrowing what's already-authenticated, not replacing it. A denial is a
-normal `isError: true` tool result (see `ToolResponse.error`), not an HTTP-level rejection — the
-model sees why, the same as any other tool failure.
+A denial is a tool result with `isError: true` — the call reached the server, the tool did not run.
 
-Roles are read via `OidcUser#hasRole` against `McpConfig.rolesClaimPath(...)` (default
-`"realm_access.roles"`, matching `OidcConfig`'s own default — set this explicitly if the two
-diverge; there's no way to read `OidcConfig`'s actual configured value from here). Scopes use
-`OidcUser#hasScope`'s built-in default claim paths (`scope`/`scp`), no extra config needed.
-`@ScopesAllowed(match = ScopesAllowed.Match.ANY)` and multi-role `@RolesAllowed({"admin",
-"editor"})` (OR semantics) both work exactly as they do on a `RequestHandler`.
-
-**`@Authenticated` alone has no effect and fails boot.** Once oidc is active for a server, every
-tool call is already authenticated — there's no per-tool public/authenticated split the way
-there is for HTTP routes, so a bare `@Authenticated` on a tool can't mean anything and would
-silently do nothing if allowed to compile. Boot fails instead, with a message pointing at
-`@RolesAllowed`/`@ScopesAllowed` as the actual narrowing mechanism.
-
-**Annotating a tool without active OAuth2 also fails boot**, not silently at request time: if
-`@RolesAllowed`/`@ScopesAllowed`/`@Authenticated` shows up on a tool while `McpSecurity` resolved
-to unprotected (`NONE`, or `AUTO` with no oidc installed), that's very likely a forgotten
-`OidcExtension` install or a `McpSecurity.NONE` left over from local dev — `IllegalStateException`
-at `app.start()`.
-
-## The `HttpException` safety net
-
-`flash-ext-oidc`'s middleware throws `HttpException.unauthorized()`/`forbidden()` on auth
-failure. Flash5's core does **not** special-case `HttpException` in the default exception
-handler — the out-of-the-box `AbstractRouter` default always returns a generic `500`, regardless
-of the thrown exception's embedded status code; only an app that explicitly calls
-`FlashApp#onException(...)` (or installs something that does) gets `HttpException.status()`
-honored.
-
-To keep the MCP endpoint correct regardless of what the rest of the app configures,
-`McpTransportGuards.httpExceptionGuard()` wraps the whole route and translates `HttpException`
-into the right HTTP status itself, rather than letting it fall through to the app's (possibly
-unconfigured) global handler. This is scoped entirely to the MCP route — it does not touch or
-override the app's `onException` for any other route.
+`McpConfig.middleware(...)` runs after authentication, for rate limiting, auditing or tracing.
diff --git a/flash-extensions/flash-ext-mcp/pom.xml b/flash-extensions/flash-ext-mcp/pom.xml
index db7f5ac..314d91a 100644
--- a/flash-extensions/flash-ext-mcp/pom.xml
+++ b/flash-extensions/flash-ext-mcp/pom.xml
@@ -19,8 +19,7 @@
         
         
             dev.relism
-            flash-ext-oidc
-            true
+            flash-ext-security-core
         
         
             com.fasterxml.jackson.core
@@ -43,6 +42,22 @@
             flash-testing
             test
         
+        
+            dev.relism
+            flash-ext-security-test
+        
+        
+            dev.relism
+            flash-ext-security-oidc
+            ${project.version}
+            test
+        
+        
+            dev.relism
+            flash-ext-security-apikey
+            ${project.version}
+            test
+        
     
 
 
diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java
deleted file mode 100644
index 088d35e..0000000
--- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java
+++ /dev/null
@@ -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.
- *
- * 

{@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}. - * - *

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 check) {} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java index a0538d7..d8268a7 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java @@ -1,5 +1,7 @@ package dev.relism.flash.ext.mcp; +import dev.relism.flash.routing.Middleware; + import java.util.ArrayList; import java.util.List; @@ -24,10 +26,10 @@ public final class McpConfig { private final String rootPath; private final String toolsPackage; private final McpSecurity security; - private final String resourceIdentifier; - private final String authorizationServerIssuer; + private final boolean requireTokenAudience; private final List allowedOrigins; private final List scopesSupported; + private final List middleware; private McpConfig(Builder b) { this.name = b.name; @@ -36,10 +38,10 @@ public final class McpConfig { this.rootPath = b.rootPath; this.toolsPackage = b.toolsPackage; this.security = b.security; - this.resourceIdentifier = b.resourceIdentifier; - this.authorizationServerIssuer = b.authorizationServerIssuer; + this.requireTokenAudience = b.requireTokenAudience; this.allowedOrigins = List.copyOf(b.allowedOrigins); this.scopesSupported = List.copyOf(b.scopesSupported); + this.middleware = List.copyOf(b.middleware); } String name() { return name; } @@ -48,10 +50,10 @@ public final class McpConfig { String rootPath() { return rootPath; } String toolsPackage() { return toolsPackage; } McpSecurity security() { return security; } - String resourceIdentifier() { return resourceIdentifier; } - String authorizationServerIssuer() { return authorizationServerIssuer; } + boolean requireTokenAudience() { return requireTokenAudience; } List allowedOrigins() { return allowedOrigins; } List scopesSupported() { return scopesSupported; } + List middleware() { return middleware; } public static Builder builder(String name) { return new Builder(name); } @@ -61,11 +63,11 @@ public final class McpConfig { private String instructions; private String rootPath = "/mcp"; private String toolsPackage; - private McpSecurity security = McpSecurity.AUTO; - private String resourceIdentifier; - private String authorizationServerIssuer; + private McpSecurity security = McpSecurity.REQUIRED; + private boolean requireTokenAudience = true; private final List allowedOrigins = new ArrayList<>(); private final List scopesSupported = new ArrayList<>(); + private final List middleware = new ArrayList<>(); private Builder(String name) { if (name == null || name.isBlank()) @@ -85,28 +87,16 @@ public final class McpConfig { /** Package scanned for {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes. Required. */ public Builder toolsPackage(String toolsPackage) { this.toolsPackage = toolsPackage; return this; } - /** OAuth2 requirement policy. Default {@link McpSecurity#AUTO}. */ + /** Default {@link McpSecurity#REQUIRED}. */ public Builder security(McpSecurity security) { this.security = security; return this; } /** - * Canonical URI of this MCP endpoint, used for RFC 8707 audience binding: tokens whose - * {@code aud} claim does not include this value are rejected. Optional — when - * {@code flash-ext-oidc} is installed, this is auto-derived per request from the - * forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own - * redirect URIs) and audience binding is enforced unconditionally. Set this explicitly - * only to override that guess — a reverse proxy that forwards neither - * {@code X-Forwarded-Host} nor {@code X-Forwarded-Proto}. + * Whether a bearer token must name this endpoint in its {@code aud} (RFC 8707), as the MCP + * authorization spec requires. Default {@code true}. Turn it off for an authorization server + * that cannot mint a resource audience — every token a registered issuer signs is then + * accepted on the endpoint, and a warning is logged at boot. */ - public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; } - - /** - * Authorization server issuer URL, published in the RFC 9728 Protected Resource - * Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}}. Optional - * — when {@code flash-ext-oidc} is installed, this is auto-derived from its configured - * issuer. Set this explicitly only to override that (e.g. publishing a different issuer - * than the one actually validating tokens). - */ - public Builder authorizationServerIssuer(String issuer) { this.authorizationServerIssuer = issuer; return this; } + public Builder requireTokenAudience(boolean require) { this.requireTokenAudience = require; return this; } /** * Origins allowed to call the MCP endpoint (DNS-rebinding protection, per the Streamable @@ -115,19 +105,15 @@ public final class McpConfig { */ public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; } - /** - * OAuth2 scopes this server expects clients to request, published as {@code - * scopes_supported} in the RFC 9728 Protected Resource Metadata document. Optional per - * the spec — omitted from the document entirely if never set. A spec-compliant client - * reads this to know what to put in its authorization/token requests instead of - * requesting nothing; see {@code docs/keycloak.md}'s "same story for any other claim" - * section for why this matters in practice (a client that requests no scope only gets - * whatever your authorization server treats as always-included, e.g. Keycloak's `basic`). - * Purely advertisement — this server still validates whatever token it actually receives - * the same way regardless of what a client requested. - */ + /** Published as {@code scopes_supported} in the RFC 9728 metadata, so OAuth clients request them. */ public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; } + /** Runs on the MCP route after the transport guards and authentication — rate limiting, auditing, tracing. */ + public Builder middleware(Middleware... middleware) { + this.middleware.addAll(List.of(middleware)); + return this; + } + public McpConfig build() { if (toolsPackage == null || toolsPackage.isBlank()) throw new IllegalStateException( diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java index 86271d1..90fe905 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java @@ -3,6 +3,8 @@ package dev.relism.flash.ext.mcp; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonNode; import dev.relism.flash.http.ContentType; +import dev.relism.flash.ext.security.SecurityIdentity; +import dev.relism.flash.ext.security.SecurityPolicy; import dev.relism.flash.models.Request; import dev.relism.flash.models.Response; @@ -18,10 +20,10 @@ import java.io.IOException; * tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object, * always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only * malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. A - * {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link McpAuthPolicy}) is the same + * {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link SecurityPolicy}) is the same * category — {@code isError: true}, tool never invoked — not a transport-level rejection; the - * route-wide 401/403 for "not authenticated at all" already happened earlier, in the {@code - * OidcMiddleware}/audience-guard middleware chain, before this dispatcher ever runs. + * route-wide 401/403 already happened earlier, in the security middleware, before this + * dispatcher ever runs. */ final class McpDispatcher { @@ -136,12 +138,14 @@ final class McpDispatcher { if (tool == null) throw McpProtocolException.invalidParams("Unknown tool: " + name); + ToolArguments args = new ToolArguments(params.path("arguments")); + SecurityPolicy policy = tool.policy(); ToolResponse result; - String denied = tool.policy() != null ? tool.policy().check().get() : null; - if (denied != null) { - result = ToolResponse.error("Tool \"" + name + "\" denied: " + denied); + 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 { - ToolArguments args = new ToolArguments(params.path("arguments")); try { result = tool.instance().call(args); } catch (Exception e) { diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java index baa0dff..82a1292 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java @@ -1,5 +1,10 @@ package dev.relism.flash.ext.mcp; +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.ext.security.SecurityExtension; +import dev.relism.flash.ext.security.SecurityIdentity; +import dev.relism.flash.ext.security.SecurityPolicy; +import dev.relism.flash.ext.security.SecurityScheme; import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashRegistrar; @@ -9,38 +14,25 @@ import lombok.extern.slf4j.Slf4j; import java.util.ArrayList; import java.util.List; +import java.util.Objects; /** - * MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single - * {@code POST} JSON-RPC endpoint, stateless in this revision (no session, no SSE stream; see - * {@code docs/transport.md}) — dispatch precompiled at boot from classes annotated with - * {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} under + * MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single {@code POST} + * JSON-RPC endpoint, stateless in this revision (see {@code docs/transport.md}) — dispatching to + * {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes under * {@link McpConfig#toolsPackage(String)}. * *

{@code
- * // Standalone, no OAuth2
- * FlashApp.create(8080)
- *     .install(new McpExtension(McpConfig.builder("my-mcp-server")
- *         .toolsPackage("com.example.tools")
- *         .build()))
- *     .start();
- *
- * // With flash-ext-oidc as the OAuth2 resource server — zero extra config: issuer, canonical
- * // resource identifier, RFC 8707 audience binding and RFC 9728 metadata are all derived from
- * // the installed OidcExtension.
- * FlashApp.create(8080)
- *     .install(new OidcExtension(oidcConfig))
- *     .install(new McpExtension(McpConfig.builder("my-mcp-server")
- *         .toolsPackage("com.example.tools")
- *         .security(McpSecurity.REQUIRED)
- *         .build()))
- *     .start();
+ * app.install(new SecurityExtension())
+ *    .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)))
+ *    .install(new McpExtension(McpConfig.builder("my-server").toolsPackage("com.example.tools").build()));
  * }
* - *

One server per {@code McpExtension} instance — install multiple instances (distinct - * {@code rootPath}, distinct {@code toolsPackage}) for multiple MCP servers on one app, - * mirroring the {@code OidcExtension} multi-tenant pattern. See {@code docs/security.md} for - * the full OAuth2 resolution rules. + *

Every call is authenticated by the application's security chain — OAuth2 bearer tokens, API + * keys, anything registered. When an OAuth2 issuer is among its schemes, the endpoint is also an + * OAuth2 protected resource: RFC 9728 metadata, a {@code resource_metadata} challenge, and RFC 8707 + * audience binding for audience-bound tokens. Tool annotations are enforced per call, with + * {@code @RolesAllowed(on = ...)} reading tool arguments. */ @Slf4j public class McpExtension implements FlashExtension { @@ -53,68 +45,54 @@ public class McpExtension implements FlashExtension { @Override public void configure(FlashRegistrar app, FlashContext ctx) { - ctx.onReady(() -> registerRoutes(app, ctx)); - } + ctx.onReady(() -> { + SecurityExtension security = config.security() == McpSecurity.NONE ? null : ctx.find(SecurityExtension.class) + .orElseThrow(() -> new IllegalStateException("MCP server \"" + config.name() + + "\" requires flash-ext-security-core: install a SecurityExtension, or set McpSecurity.NONE for a public server")); + McpDispatcher dispatcher = new McpDispatcher(McpRegistry.scan(config.toolsPackage(), ctx, security), + config.name(), config.version(), config.instructions()); - private void registerRoutes(FlashRegistrar app, FlashContext ctx) { - // Resolved before scanning so McpRegistry knows, per tool, whether @RolesAllowed/ - // @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration - // (see McpOidcIntegration#compileToolPolicy) — must run first, not after. - McpOidcIntegration.Resolved secured = resolveSecurity(ctx); - McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null, - secured == null ? null : secured.rolesClaimPath()); - McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions()); - - List 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; - } - - /** - * 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()); + List chain = new ArrayList<>(List.of( + McpTransportGuards.httpExceptionGuard(), McpTransportGuards.originGuard(config.allowedOrigins()))); + if (security != null) protect(app, security, chain); + chain.addAll(config.middleware()); + app.post(config.rootPath(), (req, res) -> { + dispatcher.handle(req, res); + return null; + }, chain.toArray(Middleware[]::new)); }); } + + private void protect(FlashRegistrar app, SecurityExtension security, List chain) { + String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath(); + chain.add(security.enforce(SecurityPolicy.AUTHENTICATED, (req, res) -> { + List 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 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 issuers(SecurityExtension security) { + return security.schemes().stream().map(SecurityScheme::issuer).filter(Objects::nonNull).toList(); + } } diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java index 426fc01..07636bd 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java @@ -19,7 +19,7 @@ import java.nio.charset.StandardCharsets; * *

Not wired to {@code flash-ext-jackson} on purpose: the MCP JSON-RPC envelope is internal * protocol plumbing, not a user-facing serialization concern, so this extension owns its - * mapper independently — same reasoning {@code flash-ext-oidc} applies to its own JSON needs + * mapper independently — the same reasoning any protocol-level extension applies to its own JSON needs * (see {@code json-smart} there). See {@code docs/jackson-interop.md} for the full rationale * and how a future opt-in reuse of a shared {@code ObjectMapper} could work. */ diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java deleted file mode 100644 index 80eae76..0000000 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java +++ /dev/null @@ -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}. - * - *

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. - * - *

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 resourceIdentifier) {} - - /** Returns the resolved security bundle, or {@code null} if oidc is not installed. */ - static Resolved resolve(FlashContext ctx, McpConfig config) { - Optional 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 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 resourceIdentifier) { - return next -> (req, res) -> { - Map 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. - * - *

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 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 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 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); - } -} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java index 4615862..a109f6b 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java @@ -2,6 +2,8 @@ package dev.relism.flash.ext.mcp; import com.fasterxml.jackson.core.JsonGenerator; import dev.relism.flash.exceptions.InitializationException; +import dev.relism.flash.ext.security.SecurityExtension; +import dev.relism.flash.ext.security.SecurityPolicy; import dev.relism.flash.extension.FlashContext; import java.io.IOException; @@ -25,7 +27,7 @@ final class McpRegistry { private static final String EMPTY_ARRAY = "[]"; /** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */ - record RegisteredTool(String name, McpTool instance, McpAuthPolicy policy) {} + record RegisteredTool(String name, McpTool instance, SecurityPolicy policy) {} record RegisteredResource(String uri, McpResource instance) {} record RegisteredPrompt(String name, McpPrompt instance) {} @@ -39,15 +41,8 @@ final class McpRegistry { private McpRegistry() {} - /** - * @param oidcActive whether this MCP server's route is actually OAuth2-protected right - * now (see {@link McpOidcIntegration#resolve}) — gates whether - * {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or - * rejected at boot as a misconfiguration; see - * {@link McpOidcIntegration#compileToolPolicy}. - * @param rolesClaimPath claim path resolved from the installed OIDC extension. - */ - static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) { + /** @param security {@code null} for a server running with {@link McpSecurity#NONE} */ + static McpRegistry scan(String packageName, FlashContext ctx, SecurityExtension security) { McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName); McpRegistry registry = new McpRegistry(); @@ -55,7 +50,9 @@ final class McpRegistry { Tool ann = cls.getAnnotation(Tool.class); McpTool instance = instantiate(cls); instance.bind(ctx); - McpAuthPolicy policy = compileToolPolicy(cls, oidcActive, rolesClaimPath); + if (security == null && SecurityPolicy.of(cls) != null) + throw new InitializationException("MCP tool \"" + ann.name() + "\" declares security annotations, but the server runs with McpSecurity.NONE"); + SecurityPolicy policy = security == null ? null : security.policy(cls); if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null) throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\""); } @@ -173,24 +170,6 @@ final class McpRegistry { gen.writeEndArray(); } - /** - * Isolated the same way {@link McpOidcIntegration#resolve} is — {@code - * NoClassDefFoundError} here means {@code flash-ext-oidc} genuinely isn't on the runtime - * classpath, in which case a tool couldn't have been compiled against - * {@code @RolesAllowed}/{@code @ScopesAllowed} in the first place, so there's nothing to - * check (and nothing lost: {@code oidcActive} is only ever {@code true} once {@link - * McpOidcIntegration#resolve} has already succeeded once this boot, which proves those - * types resolve fine). - */ - private static McpAuthPolicy compileToolPolicy(Class cls, boolean oidcActive, - String rolesClaimPath) { - try { - return McpOidcIntegration.compileToolPolicy(cls, oidcActive, rolesClaimPath); - } catch (NoClassDefFoundError e) { - return null; - } - } - private static T instantiate(Class cls) { try { Constructor ctor = cls.getDeclaredConstructor(); diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java index 55a7eb9..4fced75 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java @@ -2,18 +2,18 @@ package dev.relism.flash.ext.mcp; import java.util.List; -/** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */ +/** RFC 9728 OAuth 2.0 Protected Resource Metadata. */ final class McpResourceMetadata { private McpResourceMetadata() {} - /** {@code scopesSupported} is optional per RFC 9728 — omitted from the document if empty. */ - static String build(String resourceIdentifier, String authorizationServerIssuer, List scopesSupported) { + /** {@code scopesSupported} is optional — omitted when empty. */ + static String build(String resource, List authorizationServers, List scopesSupported) { return McpJson.buildString(gen -> { gen.writeStartObject(); - gen.writeStringField("resource", resourceIdentifier); + gen.writeStringField("resource", resource); gen.writeArrayFieldStart("authorization_servers"); - gen.writeString(authorizationServerIssuer); + for (String issuer : authorizationServers) gen.writeString(issuer); gen.writeEndArray(); if (!scopesSupported.isEmpty()) { gen.writeArrayFieldStart("scopes_supported"); diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java index 211ea5d..b8eedab 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java @@ -1,17 +1,11 @@ package dev.relism.flash.ext.mcp; -/** - * OAuth2 requirement policy for the MCP endpoint, resolved against whether - * {@code flash-ext-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}). - */ +/** Whether the MCP endpoint requires an authenticated caller. */ public enum McpSecurity { - /** Fail fast at boot if {@code flash-ext-oidc} is not installed — never expose an unprotected MCP endpoint. */ + /** The default: every call is authenticated by {@code flash-ext-security-core}, which must be installed. */ REQUIRED, - /** Protect the endpoint if {@code flash-ext-oidc} is installed; otherwise run unprotected and log a warning. */ - AUTO, - - /** Never protect the endpoint, even if {@code flash-ext-oidc} is installed elsewhere in the app. */ + /** A public endpoint. Tools declaring security annotations fail the boot. */ NONE } diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java index 94ab5a0..c6447bf 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java @@ -19,7 +19,7 @@ final class McpTransportGuards { * allowed through — only a present but disallowed value is rejected. * *

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 allowedOrigins) { if (allowedOrigins.isEmpty()) { @@ -38,7 +38,7 @@ final class McpTransportGuards { /** * Safety net around the whole MCP route: translates {@link HttpException} (thrown by - * {@link #originGuard} or by {@code flash-ext-oidc}'s middleware) into a proper HTTP status + * {@link #originGuard} or by {@code flash-ext-security-core}) into a proper HTTP status * directly, instead of relying on the app's global exception handler — which defaults to a * generic 500 for every exception type unless the app owner overrides it (see * {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java deleted file mode 100644 index 8f0bc37..0000000 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java +++ /dev/null @@ -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); } -} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java deleted file mode 100644 index af42bdb..0000000 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java +++ /dev/null @@ -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); - } - } -} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java new file mode 100644 index 0000000..cf1f253 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java @@ -0,0 +1,61 @@ +package dev.relism.flash.ext.mcp; + +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Application middleware on the MCP route. Until this existed a consumer had no way to put rate + * limiting, audit logging or tracing in front of {@code /mcp} — the chain was assembled entirely + * inside the extension. + */ +class McpConfigMiddlewareTest { + + private static final AtomicInteger CALLS = new AtomicInteger(); + + @RegisterExtension + static FlashTest mcp = FlashTest.of(app -> app.install(new McpExtension( + McpConfig.builder("middleware-server") + .version("1.0.0") + .toolsPackage("dev.relism.flash.ext.mcp.fixtures") + .security(McpSecurity.NONE) + .middleware( + next -> (req, res) -> { + CALLS.incrementAndGet(); + return next.handle(req, res); + }, + next -> (req, res) -> { + if ("deny".equals(req.header("X-Test-Gate"))) throw HttpException.forbidden(); + return next.handle(req, res); + }) + .build()))); + + @Test + void appMiddlewareRunsOnTheMcpRoute() { + int before = CALLS.get(); + mcp.request() + .json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}") + .post("/mcp") + .expectStatus(200); + assertEquals(before + 1, CALLS.get()); + } + + /** + * The point of the hook: with {@link McpSecurity#NONE}, an app's own guard is the only thing + * in front of the endpoint — which is how an app that does not authenticate with OAuth2 + * protects {@code /mcp} at all. + */ + @Test + void appMiddlewareCanRejectTheRequest() { + mcp.request() + .header("X-Test-Gate", "deny") + .json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}") + .post("/mcp") + .expectStatus(403); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java deleted file mode 100644 index 111b5ea..0000000 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java +++ /dev/null @@ -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. - * - *

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); - } - } -} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java index a3faa29..349b84a 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java @@ -16,7 +16,7 @@ class McpRegistryTest { @Test void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception { - McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), false, "realm_access.roles"); + McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), null); assertTrue(registry.hasTools()); assertTrue(registry.hasResources()); @@ -47,7 +47,7 @@ class McpRegistryTest { @Test void scan_emptyPackage_throwsInitializationException() { assertThrows(InitializationException.class, - () -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), false, "realm_access.roles")); + () -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), null)); } private static JsonNode findByField(JsonNode array, String field, String value) { diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpSecurityTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpSecurityTest.java new file mode 100644 index 0000000..a107b1b --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpSecurityTest.java @@ -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("mk", id -> null).generate(); + static final ApiKeyExtension 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 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 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")); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java deleted file mode 100644 index 9d9a622..0000000 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java +++ /dev/null @@ -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")); - } -} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java index 1bd96f5..ad3ef90 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java @@ -5,10 +5,10 @@ import dev.relism.flash.ext.mcp.TextContent; import dev.relism.flash.ext.mcp.Tool; import dev.relism.flash.ext.mcp.ToolArguments; import dev.relism.flash.ext.mcp.ToolResponse; -import dev.relism.flash.ext.oidc.RolesAllowed; +import dev.relism.flash.ext.security.RolesAllowed; @Tool(name = "admin_only", description = "Only callable with the admin role") -@RolesAllowed("admin") +@RolesAllowed(value = "admin", on = "project") public class AdminOnlyTool extends McpTool { @Override diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java index 14b390f..8526506 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java @@ -5,7 +5,7 @@ import dev.relism.flash.ext.mcp.TextContent; import dev.relism.flash.ext.mcp.Tool; import dev.relism.flash.ext.mcp.ToolArguments; import dev.relism.flash.ext.mcp.ToolResponse; -import dev.relism.flash.ext.oidc.ScopesAllowed; +import dev.relism.flash.ext.security.ScopesAllowed; @Tool(name = "write_only", description = "Only callable with the write scope") @ScopesAllowed("write") diff --git a/flash-extensions/flash-ext-oidc/README.md b/flash-extensions/flash-ext-oidc/README.md deleted file mode 100644 index 8382dce..0000000 --- a/flash-extensions/flash-ext-oidc/README.md +++ /dev/null @@ -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 - - dev.relism - flash-ext-oidc - 1.0-SNAPSHOT - -``` - -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 roles = u.roles("realm_access.roles"); // Keycloak realm roles -List clientRoles = u.roles("resource_access.my-client.roles"); // Keycloak client roles -List groups = u.roles("groups"); // Authelia - -boolean isAdmin = u.hasRole("realm_access.roles", "admin"); - -// Scopes (OIDC/OAuth2 generic): checks "scope" then "scp" -List scopes = u.scopes(); -boolean canWrite = u.hasScope("orders:write"); - -// Custom claim path resolution (for provider-specific payloads) -List 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 all = u.claims(); -``` - -### Raw access (escape hatch) - -```java -Map 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 `) — 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=""` -- invalid bearer token: `Bearer realm="", error="invalid_token"` -- insufficient scopes: `Bearer realm="", error="insufficient_scope", scope=""` - -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 -> `` -> 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 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 `

` is sufficient (no JavaScript needed): - -```html - - -
-``` - -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=&post_logout_redirect_uri=` — 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 -``` - -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. diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java deleted file mode 100644 index 4888202..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java +++ /dev/null @@ -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. - * - *

For role-based access use {@link RolesAllowed} instead (it implies authentication). - * - *

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. - * - *

{@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 { ... }
- * }
- */ -@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; -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java deleted file mode 100644 index 490147a..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java +++ /dev/null @@ -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. - * - *

Safe with virtual threads: each request gets its own virtual thread, so - * {@link ThreadLocal} values are naturally isolated per request. - * - *

{@code
- * // Inside any handler protected by @Authenticated or @RolesAllowed:
- *
- * // Preferred — typed wrapper:
- * OidcUser user = ClaimsHolder.user();
- * String email  = user.email();
- * List roles = user.roles("realm_access.roles");
- *
- * // Raw escape hatch:
- * Map all = ClaimsHolder.get();
- * }
- */ -public final class ClaimsHolder { - - private static final ThreadLocal> HOLDER = new ThreadLocal<>(); - - private ClaimsHolder() {} - - /** Called by the OIDC middleware after successful token validation. */ - static void set(Map 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. - * - *

This is the preferred entry point for both lambda and class-based handlers. - */ - public static OidcUser user() { - Map 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 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 claims = HOLDER.get(); - if (claims == null) return null; - Object v = claims.get(key); - return v != null ? v.toString() : null; - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClientAuthMethod.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClientAuthMethod.java deleted file mode 100644 index 337039f..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClientAuthMethod.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.relism.flash.ext.oidc; - -/** - * OAuth2 client authentication method for the token endpoint (RFC 6749 §2.3). - * - *

    - *
  • {@link #POST} — credentials sent as {@code client_id} / {@code client_secret} - * form fields (default; most providers).
  • - *
  • {@link #BASIC} — credentials sent as an {@code Authorization: Basic} header; - * body contains only grant-specific parameters.
  • - *
- */ -public enum ClientAuthMethod { - /** {@code client_secret_post} — credentials in the request body. */ - POST, - /** {@code client_secret_basic} — credentials in the {@code Authorization} header. */ - BASIC -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/DiscoveryClient.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/DiscoveryClient.java deleted file mode 100644 index 40b6b0c..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/DiscoveryClient.java +++ /dev/null @@ -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 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 doc = (Map) 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 doc, String key) { - Object v = doc.get(key); - if (v == null) throw new IllegalStateException( - "Discovery doc missing required field: " + key); - return v.toString(); - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/InMemoryOidcSessionStore.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/InMemoryOidcSessionStore.java deleted file mode 100644 index 3df2afc..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/InMemoryOidcSessionStore.java +++ /dev/null @@ -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}. - * - *

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 store = new ConcurrentHashMap<>(); - - @Override public void save(OidcSession s) { store.put(s.id(), s); } - @Override public Optional find(String id) { return Optional.ofNullable(store.get(id)); } - @Override public void delete(String id) { store.remove(id); } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtUtils.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtUtils.java deleted file mode 100644 index c456733..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtUtils.java +++ /dev/null @@ -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. - * - *

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 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) JSONValue.parse( - new String(payload, StandardCharsets.UTF_8)); - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtValidator.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtValidator.java deleted file mode 100644 index a0bde6f..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtValidator.java +++ /dev/null @@ -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. - * - *

Two validation modes: - *

    - *
  • {@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.
  • - *
  • {@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).
  • - *
- * - *

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 jwkSource; - private final ConfigurableJWTProcessor accessTokenProcessor; - private final ConfigurableJWTProcessor 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 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. - * - *

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 validateIdToken(String idToken, String nonce) { - try { - Map 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 buildAccessTokenProcessor( - JWKSource src, String issuer, String algorithm) { - - ConfigurableJWTProcessor 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 buildIdTokenProcessor( - JWKSource src, String issuer, String clientId, String algorithm) { - - ConfigurableJWTProcessor 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 keySelector( - JWKSource 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 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); - } - }; - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java deleted file mode 100644 index 4f7c5b1..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java +++ /dev/null @@ -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 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 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); - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java deleted file mode 100644 index 837af03..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java +++ /dev/null @@ -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()}. - * - *

Required fields: {@code issuer}, {@code clientId}, {@code clientSecret}, - * {@code redirectUri}. Everything else has a sensible default. - * - *

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. - * - *

{@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));
- * }
- */ -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. Never use in production. */ - 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: - *
-     * 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: /
-     * 
- */ - 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. - * - *
{@code
-     * OidcConfig.keycloak(
-     *     "https://keycloak.example.com", "flashboard",
-     *     "my-app", "secret", "/auth/callback")
-     *   .https()
-     *   .build();
-     * }
- */ - 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. - * Only use in development with self-signed certificates — never in production. - */ - 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. - * - *

Examples: - *

    - *
  • {@code https://keycloak.dev.home/realms/flashboard} → {@code "flashboard"}
  • - *
  • {@code https://auth.example.com} → {@code "auth.example.com"}
  • - *
- */ - 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"; - } - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java deleted file mode 100644 index 10502f7..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java +++ /dev/null @@ -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. - * - *

At {@link #provide}, the extension: - *

    - *
  1. Fetches the provider discovery document — fail-fast at startup.
  2. - *
  3. Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.
  4. - *
  5. Registers annotation processors for {@link Authenticated}, {@link RolesAllowed} - * and {@link ScopesAllowed}.
  6. - *
- * - *

At {@link #routes}, three routes are registered: - *

    - *
  • {@code GET {prefix}/login} — builds the authorization URL and redirects.
  • - *
  • {@code GET {prefix}/callback} — exchanges the code, creates a session, redirects.
  • - *
  • {@code POST {prefix}/logout} — invalidates the session, redirects to provider - * end-session endpoint (if available) or to {@link OidcConfig#postLogoutRedirectUri()}.
  • - *
- * - *
{@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));
- * }
- */ -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 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 mergeClaims(OidcTokenResponse tokens) { - Map 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. - * Only safe for development with self-signed certificates. - */ - 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 componentContributions() { - Map scopesMap = new LinkedHashMap<>(); - for (String s : config.scopes().split("\\s+")) { - if (!s.isBlank()) scopesMap.put(s, s); - } - Map flow = new LinkedHashMap<>(); - flow.put("authorizationUrl", meta.authorizationEndpoint()); - flow.put("tokenUrl", meta.tokenEndpoint()); - flow.put("scopes", scopesMap); - - Map scheme = new LinkedHashMap<>(); - scheme.put("type", "oauth2"); - scheme.put("flows", Map.of("authorizationCode", flow)); - - Map 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 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"; - } - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java deleted file mode 100644 index 7da1b7f..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java +++ /dev/null @@ -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}. - * - *

Resolution order on each request: - *

    - *
  1. {@code Authorization: Bearer ...} header — validated via JWKS ({@link JwtValidator}).
  2. - *
  3. {@code oidc_session} cookie — looked up in {@link OidcSessionStore}; transparently - * refreshed if the access token is expired.
  4. - *
  5. Browser clients (no {@code Accept: application/json}) → redirect to - * {@code {routePrefix}/login?redirect={path}}.
  6. - *
  7. API clients → 401.
  8. - *
- * - *
{@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"));
- * }
- */ -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 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). - * - *
{@code
-     * app.get("/", handler, oidc.optional());
-     * // Inside handler: ClaimsHolder.user() is non-null iff the user is logged in.
-     * }
- */ - public Middleware optional() { - return next -> (req, res) -> { - Map 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 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 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 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 resolve(Request req, Response res) { - return resolve(req, res, null); - } - - private Map 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 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 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 claims, OidcAuthPolicy policy, Response res) { - checkRoles(claims, policy.requiredRoles()); - checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res); - } - - private void checkRoles(Map claims, String[] required) { - if (required.length == 0) return; - if (rolesAllowed(claims, required)) return; - throw HttpException.forbidden(); - } - - private void checkScopes(Map 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 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 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 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 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 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 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 mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) { - Map 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; - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcProviderMetadata.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcProviderMetadata.java deleted file mode 100644 index cc783af..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcProviderMetadata.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.relism.flash.ext.oidc; - -/** - * OIDC provider endpoints discovered from {@code {issuer}/.well-known/openid-configuration}. - * - *

{@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 -) {} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSession.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSession.java deleted file mode 100644 index 742404b..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSession.java +++ /dev/null @@ -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. - * - *

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 claims; // decoded from id_token - - public OidcSession(String id, String accessToken, String idToken, - String refreshToken, Instant accessTokenExpiresAt, - Map 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 claims() { return claims; } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSessionStore.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSessionStore.java deleted file mode 100644 index b23f816..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSessionStore.java +++ /dev/null @@ -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 find(String sessionId); - void delete(String sessionId); -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcStateStore.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcStateStore.java deleted file mode 100644 index 4918cbd..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcStateStore.java +++ /dev/null @@ -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). - * - *

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 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 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())); - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcTokenResponse.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcTokenResponse.java deleted file mode 100644 index 8afad32..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcTokenResponse.java +++ /dev/null @@ -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 -) {} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java deleted file mode 100644 index d54f7f4..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java +++ /dev/null @@ -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}. - * - *

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. - * - *

{@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
- * }
- * }
- */ -public final class OidcUser { - - private final Map claims; - - OidcUser(Map 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. - * - *

Example paths: - *

    - *
  • {@code "realm_access.roles"} — Keycloak realm roles
  • - *
  • {@code "resource_access.my-client.roles"} — Keycloak client roles
  • - *
  • {@code "groups"} — Authelia / generic IdPs
  • - *
- * - * @return list of role strings, or an empty list if the path doesn't exist - */ - @SuppressWarnings("unchecked") - public List 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 scopes() { - return scopes("scope,scp"); - } - - /** - * Resolves scopes from comma-separated claim paths (example: {@code "scope,scp,permissions.scopes"}). - */ - public List scopes(String claimPaths) { - List 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 claim(String key, Class 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 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 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 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 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 == ','; - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcValidationException.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcValidationException.java deleted file mode 100644 index 202f75f..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcValidationException.java +++ /dev/null @@ -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); - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/PkceUtils.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/PkceUtils.java deleted file mode 100644 index 99fc632..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/PkceUtils.java +++ /dev/null @@ -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); - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/RolesAllowed.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/RolesAllowed.java deleted file mode 100644 index 1009f14..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/RolesAllowed.java +++ /dev/null @@ -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}. - * - *

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. - * - *

{@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 { ... }
- * }
- */ -@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(); -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ScopesAllowed.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ScopesAllowed.java deleted file mode 100644 index a5ba66f..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ScopesAllowed.java +++ /dev/null @@ -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. - * - *

Scopes are resolved from the configured claim paths in - * {@link OidcConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support - * both standard formats: - *

    - *
  • {@code scope}: space-separated string
  • - *
  • {@code scp}: string list (or string)
  • - *
- * - *
{@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 { ... }
- * }
- */ -@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 - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/TokenClient.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/TokenClient.java deleted file mode 100644 index a6be2c2..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/TokenClient.java +++ /dev/null @@ -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). - * - *

Supports two client authentication methods (RFC 6749 §2.3): - *

    - *
  • {@link ClientAuthMethod#POST} — credentials in form body ({@code client_secret_post})
  • - *
  • {@link ClientAuthMethod#BASIC} — credentials in {@code Authorization: Basic} header - * ({@code client_secret_basic})
  • - *
- */ -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 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 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 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 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 json = (Map) 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 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 m, String key, int def) { - Object v = m.get(key); - return v instanceof Number n ? n.intValue() : def; - } -} diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java deleted file mode 100644 index e1677a5..0000000 --- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java +++ /dev/null @@ -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)); - } -} diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java deleted file mode 100644 index b38a1ae..0000000 --- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java +++ /dev/null @@ -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 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 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 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\"")); - } -} diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java deleted file mode 100644 index 41b8bdf..0000000 --- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java +++ /dev/null @@ -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 responses = responses(AuthOnly.class); - assertEquals("Authentication required", responses.get(401)); - assertFalse(responses.containsKey(403)); - } - - @Test - void autoResponses_optionalAuth_addsNothing() throws Exception { - Map responses = responses(AuthOptional.class); - assertTrue(responses.isEmpty()); - } - - @Test - void autoResponses_oneRole_formatsSingular() throws Exception { - Map 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 responses = responses(MultiRole.class); - assertEquals("Roles \"admin, operator\" are required", responses.get(403)); - } - - @Test - void autoResponses_oneScope_formatsSingular() throws Exception { - Map responses = responses(OneScope.class); - assertEquals("\"orders:write\" scope required", responses.get(403)); - } - - @Test - void autoResponses_multiScopes_formatsPlural() throws Exception { - Map responses = responses(MultiScope.class); - assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403)); - } - - @Test - void autoResponses_roleAndScope_combinesMessages() throws Exception { - Map 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>> 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 responses(Class cls) throws Exception { - Map byCode = contributor().operationFor(cls).responses(); - java.util.LinkedHashMap out = new java.util.LinkedHashMap<>(); - for (Map.Entry e : byCode.entrySet()) { - out.put(e.getKey(), e.getValue().description()); - } - return out; - } -} diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java deleted file mode 100644 index 4fbcc67..0000000 --- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java +++ /dev/null @@ -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")); - } -} diff --git a/flash-extensions/flash-ext-openapi/README.md b/flash-extensions/flash-ext-openapi/README.md index b49159c..4d0c868 100644 --- a/flash-extensions/flash-ext-openapi/README.md +++ b/flash-extensions/flash-ext-openapi/README.md @@ -121,15 +121,11 @@ Merge policy: - contributor collisions use **last-wins** - manual `@APIResponse` description always wins over contributors for the same status -## OIDC interop +## Security interop -When `flash-ext-oidc` is installed, OpenAPI integrates automatically: - -- security scheme under `components.securitySchemes` -- per-operation `security` -- auto responses (class-based handlers): - - `401 Authentication required` - - `403` role/scope required messages when applicable +With `flash-ext-security-core` installed, every registered mechanism's scheme lands under +`components.securitySchemes`, and every operation carrying a security annotation lists them as +`security` alternatives with automatic `401` and — for roles or scopes — `403` responses. Manual `@APIResponse` for the same status code always wins. diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java index a420587..63c18c4 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java @@ -176,7 +176,8 @@ public final class OpenApiBuilder { } if (responseByCode.isEmpty()) { - responseByCode.put(200, Map.of("description", "OK")); + // Mutable: contributors merge descriptions and headers into it. + responseByCode.put(200, new LinkedHashMap<>(Map.of("description", "OK"))); } applyContributorResponses(responseByCode, cls); diff --git a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java index 4362b46..b3f79aa 100644 --- a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java +++ b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java @@ -101,6 +101,15 @@ class OpenApiBuilderTest { } } + @GET("/plain") + @ApiOperation(summary = "Plain") + static class PlainHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + @Schema(name = "UserDTO", title = "User model", description = "DTO", deprecated = true) @JsonIgnoreProperties({"ignoredByType"}) static class UserDto { @@ -341,6 +350,34 @@ class OpenApiBuilderTest { assertEquals("new", header.get("description")); } + @Test + void contributor_merges_into_the_default_response_of_an_operation_without_annotations() { + OpenApiBuilder b = new OpenApiBuilder(); + OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); + registry.add(new OpenApiContributor() { + @Override + public OpenApiOperationContribution operationFor(Class handlerClass) { + return OpenApiOperationContribution.builder() + .allResponses(OpenApiResponseContribution.builder() + .header("X-Trace", Map.of("schema", Map.of("type", "string"))) + .build()) + .response(401, OpenApiResponseContribution.of("Authentication required")) + .build(); + } + }); + b.setContributorRegistry(registry); + b.addOperation(OpenApiBuilder.routeOf(PlainHandler.class), PlainHandler.class.getAnnotation(ApiOperation.class), PlainHandler.class); + + Map spec = b.build(); + Map responses = cast(getOperation(spec, "/plain", "get").get("responses")); + Map resp200 = cast(responses.get("200")); + Map resp401 = cast(responses.get("401")); + Map headers = cast(resp200.get("headers")); + assertEquals("OK", resp200.get("description")); + assertTrue(headers.containsKey("X-Trace")); + assertEquals("Authentication required", resp401.get("description")); + } + private static Map getOperation(Map spec, String path, String method) { Map paths = cast(spec.get("paths")); Map pathItem = cast(paths.get(path)); diff --git a/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteRecord.java b/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteRecord.java index 8e6e357..fb2415b 100644 --- a/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteRecord.java +++ b/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteRecord.java @@ -102,7 +102,7 @@ public record RouteRecord( /** * Strips the synthetic lambda suffix ({@code $$Lambda/0x...}) from class names - * so that {@code OidcMiddleware$$Lambda/0x0000019c381f} becomes {@code OidcMiddleware}. + * so that {@code SecurityExtension$$Lambda/0x0000019c381f} becomes {@code SecurityExtension}. */ private static List buildMiddlewareNames(List> chain) { List names = new ArrayList<>(chain.size()); diff --git a/flash-extensions/flash-ext-security-apikey/docs/README.md b/flash-extensions/flash-ext-security-apikey/docs/README.md new file mode 100644 index 0000000..c4cd271 --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/docs/README.md @@ -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 _.`. + +```java +ApiKeyExtension apiKeys = new ApiKeyExtension<>("gk", id -> rows.find(id)); // ApiKeyStore +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(id, secretHash, grant, expiresAt, revokedAt)`; `G` is whatever the +application authorizes on. An authenticated caller is an `ApiKeyPrincipal` 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. diff --git a/flash-extensions/flash-ext-security-apikey/pom.xml b/flash-extensions/flash-ext-security-apikey/pom.xml new file mode 100644 index 0000000..38a31f2 --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-security-apikey + + + + dev.relism + flash-ext-security-core + + + org.junit.jupiter + junit-jupiter + + + dev.relism + flash-testing + test + + + diff --git a/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKey.java b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKey.java new file mode 100644 index 0000000..7094246 --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKey.java @@ -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(String id, String secretHash, G grant, Instant expiresAt, Instant revokedAt) { + + boolean isActive() { + return revokedAt == null && (expiresAt == null || expiresAt.toEpochMilli() > System.currentTimeMillis()); + } +} diff --git a/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyExtension.java b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyExtension.java new file mode 100644 index 0000000..87cf053 --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyExtension.java @@ -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 _.}. 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. + * + *
{@code
+ * app.install(new SecurityExtension())
+ *    .install(new ApiKeyExtension<>("gk", keys::find));
+ * }
+ */ +public final class ApiKeyExtension 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 store; + + /** @param prefix identifies this application's keys; letters and digits only */ + public ApiKeyExtension(String prefix, ApiKeyStore 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 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 + "_."); + } + + @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); + } +} diff --git a/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyPrincipal.java b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyPrincipal.java new file mode 100644 index 0000000..580a66c --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyPrincipal.java @@ -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(String name, G grant) implements Principal {} diff --git a/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyStore.java b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyStore.java new file mode 100644 index 0000000..a047890 --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyStore.java @@ -0,0 +1,9 @@ +package dev.relism.flash.ext.security.apikey; + +/** Where the application keeps its API keys. */ +@FunctionalInterface +public interface ApiKeyStore { + + /** The key with this id, or {@code null}. */ + ApiKey find(String id); +} diff --git a/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/GeneratedApiKey.java b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/GeneratedApiKey.java new file mode 100644 index 0000000..401046d --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/GeneratedApiKey.java @@ -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) {} diff --git a/flash-extensions/flash-ext-security-apikey/src/test/java/dev/relism/flash/ext/security/apikey/ApiKeyExtensionTest.java b/flash-extensions/flash-ext-security-apikey/src/test/java/dev/relism/flash/ext/security/apikey/ApiKeyExtensionTest.java new file mode 100644 index 0000000..c584bb4 --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/src/test/java/dev/relism/flash/ext/security/apikey/ApiKeyExtensionTest.java @@ -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> KEYS = new ConcurrentHashMap<>(); + static final SecurityExtension security = new SecurityExtension(); + static final ApiKeyExtension 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\""); + } +} diff --git a/flash-extensions/flash-ext-security-core/docs/README.md b/flash-extensions/flash-ext-security-core/docs/README.md new file mode 100644 index 0000000..c5390ca --- /dev/null +++ b/flash-extensions/flash-ext-security-core/docs/README.md @@ -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. diff --git a/flash-extensions/flash-ext-oidc/pom.xml b/flash-extensions/flash-ext-security-core/pom.xml similarity index 70% rename from flash-extensions/flash-ext-oidc/pom.xml rename to flash-extensions/flash-ext-security-core/pom.xml index 374d708..05c32db 100644 --- a/flash-extensions/flash-ext-oidc/pom.xml +++ b/flash-extensions/flash-ext-security-core/pom.xml @@ -10,7 +10,7 @@ 2.1.0-SNAPSHOT - flash-ext-oidc + flash-ext-security-core @@ -22,22 +22,14 @@ flash-ext-openapi true - - com.nimbusds - nimbus-jose-jwt - - - net.minidev - json-smart - - - org.projectlombok - lombok - org.junit.jupiter junit-jupiter + + dev.relism + flash-testing + test + - diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Authenticated.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Authenticated.java new file mode 100644 index 0000000..587f935 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Authenticated.java @@ -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 {} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationEntryPoint.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationEntryPoint.java new file mode 100644 index 0000000..ea56cb4 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationEntryPoint.java @@ -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; +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationFailedException.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationFailedException.java new file mode 100644 index 0000000..ae74a1f --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationFailedException.java @@ -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; + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationMechanism.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationMechanism.java new file mode 100644 index 0000000..263fe37 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationMechanism.java @@ -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; } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/InMemorySessionStore.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/InMemorySessionStore.java new file mode 100644 index 0000000..459ffeb --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/InMemorySessionStore.java @@ -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 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); + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/LoginMethod.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/LoginMethod.java new file mode 100644 index 0000000..84721a8 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/LoginMethod.java @@ -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 + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/PermitAll.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/PermitAll.java new file mode 100644 index 0000000..dc4efaf --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/PermitAll.java @@ -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 {} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Principal.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Principal.java new file mode 100644 index 0000000..e1972b2 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Principal.java @@ -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; } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/RoleResolver.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/RoleResolver.java new file mode 100644 index 0000000..05abb0b --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/RoleResolver.java @@ -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); +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/RolesAllowed.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/RolesAllowed.java new file mode 100644 index 0000000..65a60e9 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/RolesAllowed.java @@ -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 {}; +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/ScopesAllowed.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/ScopesAllowed.java new file mode 100644 index 0000000..5bb26ba --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/ScopesAllowed.java @@ -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(); +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityExtension.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityExtension.java new file mode 100644 index 0000000..6713fef --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityExtension.java @@ -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. + * + *
{@code
+ * app.install(new SecurityExtension().users(users).roles(roles))
+ *    .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)));
+ * }
+ */ +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, 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 type, SessionRefresher refresher) { + refreshers.put(type, refresher); + return this; + } + + /** The schemes of every registered mechanism, in registration order. */ + public List 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[] 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 componentContributions() { + Map 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(); + } + })); + } + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityIdentity.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityIdentity.java new file mode 100644 index 0000000..121d91c --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityIdentity.java @@ -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 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 principal(Class

type) { + return type.isInstance(principal) ? type.cast(principal) : null; + } + + /** The application user, resolved once per request by the configured {@link UserResolver}. */ + public U user(Class 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); + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityPolicy.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityPolicy.java new file mode 100644 index 0000000..891bf43 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityPolicy.java @@ -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; + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityScheme.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityScheme.java new file mode 100644 index 0000000..36c48b0 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityScheme.java @@ -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 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); + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Session.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Session.java new file mode 100644 index 0000000..478fe75 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Session.java @@ -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) {} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SessionRefresher.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SessionRefresher.java new file mode 100644 index 0000000..d4a08cd --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SessionRefresher.java @@ -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); +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SessionStore.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SessionStore.java new file mode 100644 index 0000000..3f21ad3 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SessionStore.java @@ -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); +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Target.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Target.java new file mode 100644 index 0000000..868c532 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Target.java @@ -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); +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/UserResolver.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/UserResolver.java new file mode 100644 index 0000000..085981e --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/UserResolver.java @@ -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 resolve(Principal principal); +} diff --git a/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityExtensionTest.java b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityExtensionTest.java new file mode 100644 index 0000000..c84ffd5 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityExtensionTest.java @@ -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 [ ...]}; {@code Key !} is a presented, invalid credential. */ + record KeyPrincipal(String name, Set 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")); + } +} diff --git a/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityPolicyTest.java b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityPolicyTest.java new file mode 100644 index 0000000..c733077 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityPolicyTest.java @@ -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 {} +} diff --git a/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/OpenHandler.java b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/OpenHandler.java new file mode 100644 index 0000000..a840ee3 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/OpenHandler.java @@ -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(); + } +} diff --git a/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/ProjectHandler.java b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/ProjectHandler.java new file mode 100644 index 0000000..9454cb2 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/ProjectHandler.java @@ -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(); + } +} diff --git a/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/WriteHandler.java b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/WriteHandler.java new file mode 100644 index 0000000..72ee52d --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/WriteHandler.java @@ -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"; + } +} diff --git a/flash-extensions/flash-ext-security-form/docs/README.md b/flash-extensions/flash-ext-security-form/docs/README.md new file mode 100644 index 0000000..9838c09 --- /dev/null +++ b/flash-extensions/flash-ext-security-form/docs/README.md @@ -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`. diff --git a/flash-extensions/flash-ext-security-form/pom.xml b/flash-extensions/flash-ext-security-form/pom.xml new file mode 100644 index 0000000..2da0682 --- /dev/null +++ b/flash-extensions/flash-ext-security-form/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-security-form + + + + dev.relism + flash-ext-security-core + + + org.junit.jupiter + junit-jupiter + + + dev.relism + flash-testing + test + + + diff --git a/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/FormLoginExtension.java b/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/FormLoginExtension.java new file mode 100644 index 0000000..18c0f4d --- /dev/null +++ b/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/FormLoginExtension.java @@ -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. + * + *

{@code
+ * app.install(new SecurityExtension())
+ *    .install(new FormLoginExtension(accounts::byUsername));
+ * }
+ * + * 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; + } +} diff --git a/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/PasswordEncoder.java b/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/PasswordEncoder.java new file mode 100644 index 0000000..3b8210b --- /dev/null +++ b/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/PasswordEncoder.java @@ -0,0 +1,58 @@ +package dev.relism.flash.ext.security.form; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +/** One-way password hashing. {@link #pbkdf2()} unless another is configured. */ +public interface PasswordEncoder { + + String encode(CharSequence password); + + /** Constant-time; {@code false} for anything this encoder did not produce. */ + boolean matches(CharSequence password, String encoded); + + /** PBKDF2-HMAC-SHA256, 600,000 iterations (OWASP 2023), a random 16-byte salt — JDK only. */ + static PasswordEncoder pbkdf2() { + return Pbkdf2.INSTANCE; + } + + enum Pbkdf2 implements PasswordEncoder { + INSTANCE; + + private static final int ITERATIONS = 600_000; + private static final SecureRandom RANDOM = new SecureRandom(); + + @Override + public String encode(CharSequence password) { + byte[] salt = new byte[16]; + RANDOM.nextBytes(salt); + return "pbkdf2$" + ITERATIONS + "$" + Base64.getEncoder().encodeToString(salt) + + "$" + Base64.getEncoder().encodeToString(derive(password, salt, ITERATIONS)); + } + + @Override + public boolean matches(CharSequence password, String encoded) { + String[] parts = encoded == null ? new String[0] : encoded.split("\\$"); + if (parts.length != 4 || !parts[0].equals("pbkdf2")) return false; + byte[] salt = Base64.getDecoder().decode(parts[2]); + return MessageDigest.isEqual(derive(password, salt, Integer.parseInt(parts[1])), Base64.getDecoder().decode(parts[3])); + } + + private static byte[] derive(CharSequence password, byte[] salt, int iterations) { + char[] chars = password.toString().toCharArray(); + PBEKeySpec spec = new PBEKeySpec(chars, salt, iterations, 256); + try { + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded(); + } catch (GeneralSecurityException impossible) { + throw new IllegalStateException(impossible); + } finally { + spec.clearPassword(); + java.util.Arrays.fill(chars, '\0'); + } + } + } +} diff --git a/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/PasswordStore.java b/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/PasswordStore.java new file mode 100644 index 0000000..c4fd1ef --- /dev/null +++ b/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/PasswordStore.java @@ -0,0 +1,14 @@ +package dev.relism.flash.ext.security.form; + +import dev.relism.flash.ext.security.Principal; + +/** Where the application keeps the accounts that sign in with a password. */ +@FunctionalInterface +public interface PasswordStore { + + /** The account signing in as {@code username}, or {@code null}. */ + Account find(String username); + + /** @param passwordHash as produced by the configured {@link PasswordEncoder} */ + record Account(Principal principal, String passwordHash) {} +} diff --git a/flash-extensions/flash-ext-security-form/src/test/java/dev/relism/flash/ext/security/form/FormLoginExtensionTest.java b/flash-extensions/flash-ext-security-form/src/test/java/dev/relism/flash/ext/security/form/FormLoginExtensionTest.java new file mode 100644 index 0000000..9bd0280 --- /dev/null +++ b/flash-extensions/flash-ext-security-form/src/test/java/dev/relism/flash/ext/security/form/FormLoginExtensionTest.java @@ -0,0 +1,63 @@ +package dev.relism.flash.ext.security.form; + +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.FlashResponse; +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FormLoginExtensionTest { + + static final String HASH = PasswordEncoder.pbkdf2().encode("s3cret"); + static final SecurityExtension security = new SecurityExtension(); + + @RegisterExtension + static final FlashTest app = FlashTest.of(flash -> flash + .install(security) + .install(new FormLoginExtension(username -> username.equals("alice") + ? new PasswordStore.Account(() -> "alice", HASH) : null)) + .get("/me", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED))); + + static FlashResponse login(String form, String query) { + return app.request().header("content-type", "application/x-www-form-urlencoded").body(form) + .post(FormLoginExtension.LOGIN + query); + } + + @Test + void theRightPasswordStartsASessionAndRedirectsToALocalPath() { + var response = login("username=alice&password=s3cret", "?redirect=%2Fprojects") + .expectStatus(303).expectHeader("Location", "/projects"); + String cookie = response.header("Set-Cookie"); + app.request().header("Cookie", cookie.substring(0, cookie.indexOf(';'))).get("/me").expectBody("alice"); + } + + @Test + void aForeignRedirectIsIgnored() { + login("username=alice&password=s3cret", "?redirect=%2F%2Fevil.example").expectHeader("Location", "/"); + } + + @Test + void aWrongPasswordAndAnUnknownAccountAreTheSame401() { + assertNull(login("username=alice&password=nope", "").expectStatus(401).header("Set-Cookie")); + login("username=mallory&password=s3cret", "").expectStatus(401); + } + + @Test + void pbkdf2RoundTripsAndRejectsWhatItDidNotProduce() { + PasswordEncoder encoder = PasswordEncoder.pbkdf2(); + assertTrue(encoder.matches("s3cret", HASH)); + assertFalse(encoder.matches("s3cret", "plain")); + assertFalse(encoder.matches("s3cret", null)); + } + + @Test + void theFormIsListedAsALoginMethod() { + app.get("/auth/methods").expectBodyContains("\"kind\":\"form\""); + } +} diff --git a/flash-extensions/flash-ext-security-oidc/docs/README.md b/flash-extensions/flash-ext-security-oidc/docs/README.md new file mode 100644 index 0000000..d186ba6 --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/docs/README.md @@ -0,0 +1,71 @@ +# flash-ext-security-oidc + +OpenID Connect for [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md): bearer +access tokens, and browser sign-in through the authorization code flow with PKCE. + +```java +app.install(new SecurityExtension()) + .install(new OidcExtension( + OidcProvider.of("sso", "https://id.example.com/realms/acme", "app", secret).name("Acme SSO"), + OidcProvider.of("partner", "https://login.partner.example/", "app", partnerSecret))); +``` + +Discovery runs at boot, so an unreachable provider fails the start rather than the first sign-in. + +## Bearer tokens + +`Authorization: Bearer ` is matched to its provider by `iss`, then verified against that +provider's keys (RS/PS/ES algorithms, `typ` `JWT` or `at+jwt`, `iss`, `sub`, `exp`). One parse, one map +lookup, however many providers are configured. A token from an unconfigured issuer is left to other +mechanisms; a token from a configured one that fails verification is `401 invalid_token`. + +## Sign-in + +| Route | | +|---|---| +| `GET /auth/oidc/{id}/login?redirect=/path` | redirects to the provider | +| `GET /auth/oidc/{id}/callback` | exchanges the code, verifies the ID token and nonce, starts a session | + +The PKCE verifier, nonce and state travel in a short-lived `HttpOnly` cookie scoped to `/auth/oidc`, +so sign-in needs no server-side state and works across instances. The session's principal is renewed +with the refresh token when its access token expires; `POST /auth/logout` ends it and continues to the +provider's `end_session_endpoint`. The client authenticates with `client_secret_basic`. Register +`{origin}/auth/oidc/{id}/callback` as a redirect URI and `{origin}/` as a post-logout redirect URI; +behind a proxy, forward `X-Forwarded-Proto` and `X-Forwarded-Host`. + +Each provider is listed at `/auth/methods` (`"kind":"redirect"`) and published to OpenAPI as an +`openIdConnect` scheme. + +## Providers added at runtime + +```java +OidcExtension oidc = new OidcExtension(central); +oidc.register(OidcProvider.of("acme", "https://login.acme.example/", clientId, secret)); // an organization's own IdP +oidc.unregister("acme"); +``` + +Discovery runs inside `register`, which refuses a provider — or any endpoint its discovery names — that +is not https on a public address: registration makes the server fetch URLs someone else chose. +`allowLocalProviders()` lifts that for development. Registered providers serve bearer tokens and +`/auth/oidc/{id}/login` immediately, but are not listed at `/auth/methods` or in OpenAPI: which provider +a given user signs in with is the application's decision — typically an `AuthenticationEntryPoint` that +picks one from the email domain. Their issuer is also a tenant boundary the application must enforce +in its `UserResolver`: a user belongs to the organizations whose issuer vouched for them. + +## Principal and roles + +`OidcPrincipal` carries the verified claims (`issuer()`, `name()` = `sub`, `email()`, `claim(...)`), +the access token and, for sessions, the refresh token. `hasScope` reads `scope`/`scp`; `hasAudience` +reads `aud`. + +Roles are the application's decision. To take them from the token instead: + +```java +new SecurityExtension().roles(ClaimRoles.at("realm_access.roles")) // Keycloak; "groups" for most others +``` + +## Testing + +`FakeOidcProvider` in [`flash-ext-security-test`](../../flash-ext-security-test/docs/README.md) serves +discovery, keys and the code and refresh flows on a local port; `OidcTokens.passwordGrant(...)` gets a +real token from a real provider such as Keycloak in Testcontainers. diff --git a/flash-extensions/flash-ext-security-oidc/pom.xml b/flash-extensions/flash-ext-security-oidc/pom.xml new file mode 100644 index 0000000..50cdd8d --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-security-oidc + + + + dev.relism + flash-ext-security-core + + + com.nimbusds + nimbus-jose-jwt + + + org.junit.jupiter + junit-jupiter + + + dev.relism + flash-ext-security-test + + + diff --git a/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/ClaimRoles.java b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/ClaimRoles.java new file mode 100644 index 0000000..0af47cb --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/ClaimRoles.java @@ -0,0 +1,38 @@ +package dev.relism.flash.ext.security.oidc; + +import dev.relism.flash.ext.security.Principal; +import dev.relism.flash.ext.security.RoleResolver; +import dev.relism.flash.ext.security.SecurityIdentity; +import dev.relism.flash.ext.security.Target; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** Roles read from the provider's token instead of the application's own data. */ +public final class ClaimRoles implements RoleResolver { + + private final String[] path; + + private ClaimRoles(String claimPath) { + this.path = claimPath.split("\\."); + } + + /** Roles at a dot-separated claim path — {@code realm_access.roles} for Keycloak, {@code groups} for most others. */ + public static ClaimRoles at(String claimPath) { + return new ClaimRoles(claimPath); + } + + /** The roles {@code principal}'s token carries; none for a caller OIDC did not authenticate. */ + @SuppressWarnings("unchecked") + public List of(Principal principal) { + Object value = principal instanceof OidcPrincipal oidc ? oidc.claims() : null; + for (int i = 0; i < path.length && value != null; i++) value = value instanceof Map map ? map.get(path[i]) : null; + return value instanceof List roles ? (List) roles : value instanceof Collection roles ? List.copyOf((Collection) roles) : List.of(); + } + + @Override + public boolean hasRole(SecurityIdentity identity, String role, Target on) { + return of(identity.principal()).contains(role); + } +} diff --git a/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcExtension.java b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcExtension.java new file mode 100644 index 0000000..5bda112 --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcExtension.java @@ -0,0 +1,239 @@ +package dev.relism.flash.ext.security.oidc; + +import com.nimbusds.jwt.SignedJWT; +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.ext.security.AuthenticationFailedException; +import dev.relism.flash.ext.security.AuthenticationMechanism; +import dev.relism.flash.ext.security.LoginMethod; +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.ext.security.Session; +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 dev.relism.flash.models.Response; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * OpenID Connect: bearer access tokens from any configured provider, and browser sign-in through the + * authorization code flow with PKCE, which ends in a {@link SecurityExtension} session renewed with + * the refresh token. + * + *
{@code
+ * app.install(new SecurityExtension())
+ *    .install(new OidcExtension(OidcProvider.of("sso", "https://id.example.com/realms/acme", "app", secret)));
+ * }
+ * + *

Routes: {@code GET /auth/oidc/{provider}/login?redirect=/path} and its callback. A bearer token + * is matched to its provider by {@code iss} before its signature is checked against that provider's + * keys, so any number of providers costs one lookup; a token from an issuer not configured here is + * left to other mechanisms. + */ +public final class OidcExtension implements FlashExtension, AuthenticationMechanism { + + private static final AuthenticationFailedException INVALID = new AuthenticationFailedException("Bearer error=\"invalid_token\""); + private static final String FLOW = "flash_oidc"; + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Base64.Encoder BASE64URL = Base64.getUrlEncoder().withoutPadding(); + + private final OidcProvider[] configured; + private final Map byId = new ConcurrentHashMap<>(); + private final Map byIssuer = new ConcurrentHashMap<>(); + private SecurityExtension security; + private boolean localProviders; + + public OidcExtension(OidcProvider... providers) { + this.configured = providers; + } + + /** Lets {@link #register} accept http and private addresses — for a provider on a developer's machine, never in production. */ + public OidcExtension allowLocalProviders() { + this.localProviders = true; + return this; + } + + /** + * Trusts another provider from now on — an organization connecting its own. Discovery runs here, + * so a bad provider fails this call. It serves bearer tokens and {@code /auth/oidc/{id}/login}, but is + * not listed at {@code /auth/methods}: which provider a user signs in with is the application's call. + * + * @throws IllegalArgumentException the provider, or an endpoint it names, is not https on a public address, + * or its id is a configured provider's, which it would otherwise replace + */ + public void register(OidcProvider config) { + for (OidcProvider fixed : configured) { + if (fixed.id().equals(config.id())) throw new IllegalArgumentException("A configured provider already uses the id " + config.id()); + } + Provider provider = new Provider(config, !localProviders); + Provider replaced = byId.put(config.id(), provider); + if (replaced != null) byIssuer.remove(replaced.issuer); + byIssuer.put(provider.issuer, provider); + } + + /** + * Checks a provider before trusting it: discovery, the client ID and secret, and the redirect URI a + * sign-in from {@code origin} will send. Registers nothing, and holds {@link #register}'s address rules. + * + * @param origin where users will sign in from, as {@link Request#origin()} gives it + * @throws IllegalArgumentException naming what the provider refused + * @throws IllegalStateException the provider could not be reached + */ + public void verify(OidcProvider config, String origin) { + new Provider(config, !localProviders).verify(callbackUri(origin, config.id())); + } + + /** Stops trusting a provider: its tokens are no longer this mechanism's, its sessions stop renewing. */ + public void unregister(String id) { + Provider removed = byId.remove(id); + if (removed != null) byIssuer.remove(removed.issuer); + } + + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + for (OidcProvider config : configured) { + Provider provider = new Provider(config, false); + byId.put(config.id(), provider); + byIssuer.put(provider.issuer, provider); + } + ctx.provide(OidcExtension.class, this); + app.get("/auth/oidc/{provider}/login", this::login); + app.get("/auth/oidc/{provider}/callback", this::callback); + ctx.onReady(() -> { + security = ctx.require(SecurityExtension.class).mechanism(this).refresher(OidcPrincipal.class, this::refresh); + for (OidcProvider config : configured) { + security.scheme(SecurityScheme.openIdConnect(config.id(), byId.get(config.id()).issuer)) + .loginMethod(new LoginMethod(config.id(), config.name(), "/auth/oidc/" + config.id() + "/login", LoginMethod.Kind.REDIRECT)); + } + }); + } + + // -- Bearer access tokens ------------------------------------------------- + + @Override + public Principal authenticate(Request req) { + String header = req.header("Authorization"); + if (header == null || !header.startsWith("Bearer ") || header.indexOf('.') < 0 || header.indexOf('.') == header.lastIndexOf('.')) return null; + String token = header.substring(7); + Provider provider; + SignedJWT jwt; + try { + jwt = SignedJWT.parse(token); + provider = byIssuer.get(String.valueOf(jwt.getJWTClaimsSet().getIssuer())); + } catch (Exception malformed) { + throw INVALID; + } + if (provider == null) return null; + try { + Map claims = provider.verifyAccessToken(jwt); + return new OidcPrincipal(provider.config.id(), (String) claims.get("sub"), claims, token, null, null); + } catch (Exception invalid) { + throw INVALID; + } + } + + // -- Browser sign-in -------------------------------------------------------- + + private Object login(Request req, Response res) throws Exception { + Provider provider = provider(req); + String state = random(16); + String verifier = random(32); + String nonce = random(16); + res.header("Set-Cookie", FLOW + "=" + state + "." + verifier + "." + nonce + "." + + BASE64URL.encodeToString(localPath(req.query("redirect")).getBytes(StandardCharsets.UTF_8)) + + "; Path=/auth/oidc; Max-Age=600; HttpOnly; SameSite=Lax" + (req.origin().startsWith("https") ? "; Secure" : "")); + String challenge = BASE64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII))); + res.redirect(provider.authorizeUrl(callbackUri(req.origin(), provider.config.id()), state, nonce, challenge)); + return null; + } + + private Object callback(Request req, Response res) { + Provider provider = provider(req); + String cookie = req.cookie(FLOW); + String[] flow = cookie == null ? null : cookie.split("\\."); + String state = req.query("state"); + res.header("Set-Cookie", FLOW + "=; Path=/auth/oidc; Max-Age=0"); + if (flow == null || flow.length != 4 || state == null + || !MessageDigest.isEqual(state.getBytes(StandardCharsets.US_ASCII), flow[0].getBytes(StandardCharsets.US_ASCII))) { + throw HttpException.badRequest("Sign-in state does not match; start again"); + } + if (req.query("error") != null) throw HttpException.badRequest("The identity provider refused sign-in: " + req.query("error")); + try { + Map tokens = provider.token("grant_type=authorization_code&code=" + Provider.encode(req.query("code")) + + "&redirect_uri=" + Provider.encode(callbackUri(req.origin(), provider.config.id())) + "&code_verifier=" + flow[1]); + String idToken = (String) tokens.get("id_token"); + Map claims = sessionClaims(provider.verifyIdToken(idToken), tokens); + if (!flow[2].equals(claims.get("nonce"))) throw new IllegalStateException("nonce mismatch"); + String logout = provider.endSessionEndpoint == null ? null : provider.endSessionEndpoint + + (provider.endSessionEndpoint.indexOf('?') < 0 ? '?' : '&') + "client_id=" + Provider.encode(provider.config.clientId()) + + "&id_token_hint=" + idToken + "&post_logout_redirect_uri=" + Provider.encode(req.origin() + "/"); + OidcPrincipal principal = new OidcPrincipal(provider.config.id(), (String) claims.get("sub"), claims, + (String) tokens.get("access_token"), (String) tokens.get("refresh_token"), logout); + security.signIn(req, res, principal, expiry(tokens)); + } catch (Exception failed) { + throw HttpException.badRequest("Sign-in could not be completed; start again"); + } + res.redirect(localPath(new String(Base64.getUrlDecoder().decode(flow[3]), StandardCharsets.UTF_8))); + return null; + } + + private Session refresh(Session expired) { + OidcPrincipal old = (OidcPrincipal) expired.principal(); + Provider provider = byId.get(old.provider()); + if (provider == null || old.refreshToken() == null) return null; + try { + Map tokens = provider.token("grant_type=refresh_token&refresh_token=" + Provider.encode(old.refreshToken())); + Map claims = tokens.get("id_token") == null ? old.claims() : sessionClaims(provider.verifyIdToken(tokens.get("id_token")), tokens); + if (!old.name().equals(claims.get("sub"))) return null; + Object refreshToken = tokens.getOrDefault("refresh_token", old.refreshToken()); + return new Session(expired.id(), new OidcPrincipal(old.provider(), old.name(), claims, + (String) tokens.get("access_token"), (String) refreshToken, old.logoutUrl()), expiry(tokens)); + } catch (Exception failed) { + return null; + } + } + + // -- Helpers ---------------------------------------------------------------- + + private Provider provider(Request req) { + Provider provider = byId.get(req.param("provider")); + if (provider == null) throw HttpException.notFound("OIDC provider"); + return provider; + } + + /** The ID token's claims plus the scopes the token response granted, which ID tokens do not carry. */ + private static Map sessionClaims(Map idClaims, Map tokens) { + if (tokens.get("scope") == null) return idClaims; + Map claims = new HashMap<>(idClaims); + claims.put("scope", tokens.get("scope")); + return claims; + } + + private static Instant expiry(Map tokens) { + return Instant.now().plusSeconds(tokens.get("expires_in") instanceof Number seconds ? seconds.longValue() : 300); + } + + private static String callbackUri(String origin, String provider) { + return origin + "/auth/oidc/" + provider + "/callback"; + } + + /** Only same-origin paths: anything else would make sign-in an open redirect. */ + private static String localPath(String path) { + return path != null && path.startsWith("/") && !path.startsWith("//") && !path.startsWith("/\\") ? path : "/"; + } + + private static String random(int bytes) { + byte[] value = new byte[bytes]; + RANDOM.nextBytes(value); + return BASE64URL.encodeToString(value); + } +} diff --git a/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcPrincipal.java b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcPrincipal.java new file mode 100644 index 0000000..626b495 --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcPrincipal.java @@ -0,0 +1,49 @@ +package dev.relism.flash.ext.security.oidc; + +import dev.relism.flash.ext.security.Principal; + +import java.util.Collection; +import java.util.Map; + +/** + * A caller an OpenID Provider vouched for: through a bearer access token, or by signing in. Its + * {@link #name()} is the {@code sub} claim — unique per {@link #issuer()}, never across issuers. + * + * @param provider the {@link OidcProvider#id()} + * @param claims verified: the access token's for a bearer caller, the ID token's for a session + * @param refreshToken {@code null} for a bearer caller + */ +public record OidcPrincipal(String provider, String name, Map claims, + String accessToken, String refreshToken, String logoutUrl) implements Principal { + + public String issuer() { + return (String) claims.get("iss"); + } + + public String email() { + return (String) claims.get("email"); + } + + public Object claim(String name) { + return claims.get(name); + } + + /** From {@code scope} (space-delimited) or {@code scp}; a token that grants none grants none. */ + @Override + public boolean hasScope(String scope) { + Object granted = claims.containsKey("scope") ? claims.get("scope") : claims.get("scp"); + if (granted instanceof Collection list) return list.contains(scope); + if (!(granted instanceof String delimited)) return false; + for (int at = delimited.indexOf(scope); at >= 0; at = delimited.indexOf(scope, at + 1)) { + int end = at + scope.length(); + if ((at == 0 || delimited.charAt(at - 1) == ' ') && (end == delimited.length() || delimited.charAt(end) == ' ')) return true; + } + return false; + } + + @Override + public boolean hasAudience(String audience) { + Object aud = claims.get("aud"); + return aud instanceof Collection list ? list.contains(audience) : audience.equals(aud); + } +} diff --git a/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcProvider.java b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcProvider.java new file mode 100644 index 0000000..d960b45 --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcProvider.java @@ -0,0 +1,32 @@ +package dev.relism.flash.ext.security.oidc; + +/** + * An OpenID Provider the application trusts, and the client the application is registered as there. + * + * @param id names the provider in routes ({@code /auth/oidc/{id}/login}), OpenAPI and principals + * @param name what a sign-in page shows + * @param issuer exactly as the provider publishes it — some end in {@code /} + * @param scopes requested at sign-in, space-delimited + */ +public record OidcProvider(String id, String name, String issuer, String clientId, String clientSecret, String scopes) { + + public OidcProvider { + if (!id.matches("[A-Za-z0-9_-]+")) throw new IllegalArgumentException("OIDC provider id must be URL-safe: " + id); + } + + public static OidcProvider of(String id, String issuer, String clientId, String clientSecret) { + return new OidcProvider(id, id, issuer, clientId, clientSecret, "openid profile email"); + } + + public OidcProvider name(String name) { + return new OidcProvider(id, name, issuer, clientId, clientSecret, scopes); + } + + public OidcProvider scopes(String scopes) { + return new OidcProvider(id, name, issuer, clientId, clientSecret, scopes); + } + + String discoveryUrl() { + return issuer + (issuer.endsWith("/") ? "" : "/") + ".well-known/openid-configuration"; + } +} diff --git a/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/Provider.java b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/Provider.java new file mode 100644 index 0000000..de98265 --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/Provider.java @@ -0,0 +1,164 @@ +package dev.relism.flash.ext.security.oidc; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jose.util.JSONObjectUtils; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; + +import java.net.Inet6Address; +import java.net.InetAddress; +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.HashSet; +import java.util.Map; +import java.util.Set; + +/** One discovered provider: its endpoints, its signing keys, and the two token verifiers they back. */ +final class Provider { + + private static final HttpClient HTTP = HttpClient.newHttpClient(); + + final OidcProvider config; + final String issuer; + final String authorizationEndpoint; + final String endSessionEndpoint; + private final String tokenEndpoint; + private final String clientAuthorization; + private final DefaultJWTProcessor accessTokens; + private final DefaultJWTProcessor idTokens; + + /** + * Fetches discovery. {@code guarded} providers — registered at runtime, from input the operator does + * not control — must be https on public addresses, and so must every endpoint their discovery names: + * otherwise registering one is a request forgery against the server's own network. + */ + Provider(OidcProvider config, boolean guarded) { + this.config = config; + try { + if (guarded) requirePublic(config.discoveryUrl()); + Map discovery = JSONObjectUtils.parse(HTTP.send(HttpRequest.newBuilder(URI.create(config.discoveryUrl())).build(), + HttpResponse.BodyHandlers.ofString()).body()); + if (guarded) for (String key : new String[]{"authorization_endpoint", "token_endpoint", "jwks_uri"}) requirePublic((String) discovery.get(key)); + issuer = (String) discovery.get("issuer"); + authorizationEndpoint = (String) discovery.get("authorization_endpoint"); + tokenEndpoint = (String) discovery.get("token_endpoint"); + endSessionEndpoint = (String) discovery.get("end_session_endpoint"); + JWKSource keys = JWKSourceBuilder.create(URI.create((String) discovery.get("jwks_uri")).toURL()).retrying(true).build(); + accessTokens = processor(keys, null, new JOSEObjectType("at+jwt")); + idTokens = processor(keys, config.clientId(), null); + } catch (Exception e) { + if (e instanceof IllegalArgumentException rejected) throw rejected; + throw new IllegalStateException("OIDC discovery failed for " + config.discoveryUrl(), e); + } + if (issuer == null || authorizationEndpoint == null || tokenEndpoint == null) { + throw new IllegalStateException("Incomplete OIDC discovery document at " + config.discoveryUrl()); + } + clientAuthorization = "Basic " + Base64.getEncoder().encodeToString( + (encode(config.clientId()) + ":" + encode(config.clientSecret())).getBytes(StandardCharsets.UTF_8)); + } + + Map verifyAccessToken(SignedJWT token) throws Exception { + return accessTokens.process(token, null).getClaims(); + } + + Map verifyIdToken(Object idToken) throws Exception { + if (!(idToken instanceof String jwt)) throw new IllegalStateException("The provider returned no ID token"); + return idTokens.process(jwt, null).getClaims(); + } + + /** The sign-in request. {@link #verify} sends this same one, so it is refused exactly when a real sign-in would be. */ + String authorizeUrl(String redirectUri, String state, String nonce, String challenge) { + return authorizationEndpoint + (authorizationEndpoint.indexOf('?') < 0 ? '?' : '&') + + "response_type=code&client_id=" + encode(config.clientId()) + + "&redirect_uri=" + encode(redirectUri) + + "&scope=" + encode(config.scopes()) + + "&state=" + state + "&nonce=" + nonce + "&code_challenge=" + challenge + "&code_challenge_method=S256"; + } + + /** A token endpoint call, authenticated as the client ({@code client_secret_basic}). */ + Map token(String form) throws Exception { + HttpResponse response = HTTP.send(HttpRequest.newBuilder(URI.create(tokenEndpoint)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Authorization", clientAuthorization) + .POST(HttpRequest.BodyPublishers.ofString(form)).build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) throw new IllegalStateException("Token endpoint answered " + response.statusCode()); + return JSONObjectUtils.parse(response.body()); + } + + /** + * Throws naming what the provider refuses, without anyone signing in. Client authentication is + * checked before the grant (RFC 6749 §5.2), so a made-up code tells a bad secret (401) from a good + * one. A redirect URI the client has not registered is answered with an error page, never a + * redirect (§4.1.2.1), but not always on the first hop: some providers bounce to their login page + * first, so same-host redirects are followed. + */ + void verify(String redirectUri) { + try { + HttpResponse token = HTTP.send(HttpRequest.newBuilder(URI.create(tokenEndpoint)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Authorization", clientAuthorization) + .POST(HttpRequest.BodyPublishers.ofString("grant_type=authorization_code&code=verify&redirect_uri=" + encode(redirectUri))) + .build(), HttpResponse.BodyHandlers.ofString()); + String error = token.body().contains("\"error\"") ? (String) JSONObjectUtils.parse(token.body()).get("error") : null; + if (token.statusCode() == 401 || "invalid_client".equals(error) || "unauthorized_client".equals(error)) { + throw new IllegalArgumentException("The provider rejected the client ID or secret"); + } + // RFC 7636's example challenge: well-formed, so a provider that insists on PKCE judges the rest. + URI hop = URI.create(authorizeUrl(redirectUri, "verify", "verify", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")); + String authority = hop.getAuthority(); + for (int i = 0; i < 5; i++) { + HttpResponse answer = HTTP.send(HttpRequest.newBuilder(hop).build(), HttpResponse.BodyHandlers.discarding()); + if (answer.statusCode() >= 400) throw new IllegalArgumentException("The provider does not accept the redirect URI " + redirectUri); + String location = answer.headers().firstValue("Location").orElse(null); + if (answer.statusCode() / 100 != 3 || location == null) return; + hop = hop.resolve(location); + // Back to the client, or onwards to a login somewhere else: either way it was accepted. Authority, + // not host: in development the provider and the client share localhost on different ports. + if (hop.toString().startsWith(redirectUri) || !authority.equals(hop.getAuthority())) return; + } + } catch (IllegalArgumentException refused) { + throw refused; + } catch (Exception unreachable) { + throw new IllegalStateException("Could not reach " + config.issuer(), unreachable); + } + } + + /** ponytail: resolved once here and again by the HTTP client, so DNS rebinding between the two is not covered. */ + private static void requirePublic(String url) throws Exception { + URI uri = URI.create(url); + if (!"https".equals(uri.getScheme())) throw new IllegalArgumentException("Not https: " + url); + for (InetAddress address : InetAddress.getAllByName(uri.getHost())) { + if (address.isLoopbackAddress() || address.isSiteLocalAddress() || address.isLinkLocalAddress() || address.isAnyLocalAddress() + || address.isMulticastAddress() || (address instanceof Inet6Address && (address.getAddress()[0] & 0xfe) == 0xfc)) { + throw new IllegalArgumentException("Not a public address: " + url); + } + } + } + + static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private DefaultJWTProcessor processor(JWKSource keys, String audience, JOSEObjectType type) { + Set algorithms = new HashSet<>(JWSAlgorithm.Family.RSA); + algorithms.addAll(JWSAlgorithm.Family.EC); + DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); + processor.setJWSKeySelector(new JWSVerificationKeySelector<>(algorithms, keys)); + processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(audience, new JWTClaimsSet.Builder().issuer(issuer).build(), Set.of("sub", "exp"))); + if (type != null) processor.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(JOSEObjectType.JWT, type, null)); + return processor; + } +} diff --git a/flash-extensions/flash-ext-security-oidc/src/test/java/dev/relism/flash/ext/security/oidc/OidcExtensionTest.java b/flash-extensions/flash-ext-security-oidc/src/test/java/dev/relism/flash/ext/security/oidc/OidcExtensionTest.java new file mode 100644 index 0000000..47c3b62 --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/src/test/java/dev/relism/flash/ext/security/oidc/OidcExtensionTest.java @@ -0,0 +1,167 @@ +package dev.relism.flash.ext.security.oidc; + +import dev.relism.flash.ext.security.RolesAllowed; +import dev.relism.flash.ext.security.ScopesAllowed; +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.test.FakeOidcProvider; +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.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OidcExtensionTest { + + static final FakeOidcProvider provider = start(); + static final FakeOidcProvider stranger = start(); + static final SecurityExtension security = new SecurityExtension().roles(ClaimRoles.at("realm_access.roles")); + + @RolesAllowed("admin") static class AdminOnly {} + @ScopesAllowed("write") static class Writers {} + + @RegisterExtension + static final FlashTest app = FlashTest.of(flash -> flash + .install(security) + .install(new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret").name("Fake"))) + .get("/me", (req, res) -> { + OidcPrincipal principal = SecurityIdentity.current().principal(OidcPrincipal.class); + return principal.name() + " " + principal.email(); + }, security.enforce(SecurityPolicy.AUTHENTICATED)) + .get("/admin", (req, res) -> "admin", security.enforce(SecurityPolicy.of(AdminOnly.class))) + .get("/write", (req, res) -> "write", security.enforce(SecurityPolicy.of(Writers.class)))); + + static FakeOidcProvider start() { + try { + return new FakeOidcProvider(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + // -- Bearer --------------------------------------------------------------- + + @Test + void aBearerTokenFromTheProviderAuthenticates() { + app.request().with(provider.bearer("bob", Map.of("email", "bob@example.test"))).get("/me") + .expectStatus(200).expectBody("bob bob@example.test"); + } + + @Test + void aTamperedTokenIsInvalid() { + String token = provider.token("bob", Map.of()); + app.request().header("Authorization", "Bearer " + token.substring(0, token.length() - 4) + "AAAA").get("/me") + .expectStatus(401).expectHeader("WWW-Authenticate", "Bearer error=\"invalid_token\""); + } + + /** Not rejected as invalid: an issuer this application does not know is some other mechanism's business. */ + @Test + void aTokenFromAnUnknownIssuerIsNotThisMechanisms() { + app.request().with(stranger.bearer("bob", Map.of())).get("/me") + .expectStatus(401).expectHeader("WWW-Authenticate", "Bearer realm=\"fake\""); + } + + @Test + void rolesAndScopesAreReadFromTheToken() { + app.request().with(provider.bearer("root", Map.of("realm_access", Map.of("roles", List.of("admin"))))).get("/admin").expectStatus(200); + app.request().with(provider.bearer("bob", Map.of("realm_access", Map.of("roles", List.of("user"))))).get("/admin").expectStatus(403); + app.request().with(provider.bearer("bob", Map.of("scope", "read write"))).get("/write").expectStatus(200); + app.request().with(provider.bearer("bob", Map.of("scope", "read"))).get("/write").expectStatus(403); + } + + // -- Browser sign-in -------------------------------------------------------- + + @Test + void signingInEndsInASessionThatRefreshesAndSignsOutAtTheProvider() throws Exception { + provider.signInAs("carol", Map.of("email", "carol@example.test")).expiresIn(0); + + FlashResponse login = app.request().get("/auth/oidc/fake/login?redirect=%2Fme").expectStatus(302); + String flow = cookie(login.headers().allValues("Set-Cookie"), "flash_oidc"); + HttpResponse approved = HttpClient.newHttpClient().send( + HttpRequest.newBuilder(URI.create(login.header("Location"))).build(), HttpResponse.BodyHandlers.ofString()); + String callback = URI.create(approved.headers().firstValue("Location").orElseThrow()).getRawPath() + + "?" + URI.create(approved.headers().firstValue("Location").orElseThrow()).getRawQuery(); + + FlashResponse signedIn = app.request().header("Cookie", flow).get(callback).expectStatus(302).expectHeader("Location", "/me"); + String session = cookie(signedIn.headers().allValues("Set-Cookie"), "flash_session"); + + // expiresIn(0): the session is already expired, so this answer came through the refresh token. + app.request().header("Cookie", session).get("/me").expectStatus(200).expectBody("carol carol@example.test"); + String logout = app.request().header("Cookie", session).post("/auth/logout").expectStatus(303).header("Location"); + assertTrue(logout.startsWith(provider.issuer() + "/logout?client_id=app&id_token_hint="), logout); + } + + @Test + void aCallbackWithoutItsFlowCookieIsRefused() { + app.get("/auth/oidc/fake/callback?code=x&state=y").expectStatus(400); + app.get("/auth/oidc/nobody/login").expectStatus(404); + } + + @Test + void theProviderIsListedAndDocumented() { + app.get("/auth/methods").expectBodyContains("{\"id\":\"fake\",\"name\":\"Fake\",\"url\":\"/auth/oidc/fake/login\",\"kind\":\"redirect\"}"); + } + + @Test + void aProviderRegisteredAtRuntimeIsTrustedUntilUnregistered() throws Exception { + OidcExtension extension = new OidcExtension().allowLocalProviders(); + SecurityExtension own = new SecurityExtension(); + FlashTest runtime = FlashTest.of(flash -> flash.install(own).install(extension) + .get("/me", (req, res) -> SecurityIdentity.current().principal().name(), own.enforce(SecurityPolicy.AUTHENTICATED))); + try { + runtime.get("/me").expectStatus(401); + extension.register(OidcProvider.of("byo", stranger.issuer(), "app", "secret")); + runtime.request().with(stranger.bearer("dora", Map.of())).get("/me").expectStatus(200).expectBody("dora"); + extension.unregister("byo"); + runtime.request().with(stranger.bearer("dora", Map.of())).get("/me").expectStatus(401); + } finally { + runtime.app().stop().join(); + } + } + + /** Checked before anyone trusts it, and told apart: a bad secret is not a bad redirect URI. */ + @Test + void aProviderIsVerifiedBeforeItIsTrusted() throws Exception { + try (FakeOidcProvider customer = new FakeOidcProvider().client("s3cret", "https://app.example/auth/oidc/acme/callback")) { + OidcExtension extension = new OidcExtension().allowLocalProviders(); + extension.verify(OidcProvider.of("acme", customer.issuer(), "app", "s3cret"), "https://app.example"); + + assertTrue(assertThrows(IllegalArgumentException.class, + () -> extension.verify(OidcProvider.of("acme", customer.issuer(), "app", "wrong"), "https://app.example")) + .getMessage().contains("client ID or secret")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> extension.verify(OidcProvider.of("acme", customer.issuer(), "app", "s3cret"), "https://elsewhere.example")) + .getMessage().contains("https://elsewhere.example/auth/oidc/acme/callback")); + // Verifying fetches URLs a customer chose, so it keeps register's address rules. + assertThrows(IllegalArgumentException.class, + () -> new OidcExtension().verify(OidcProvider.of("acme", customer.issuer(), "app", "s3cret"), "https://app.example")); + } + } + + /** A provider registered at runtime, from a customer's input, never replaces one the application configured. */ + @Test + void aRuntimeProviderCannotTakeAConfiguredId() { + OidcExtension extension = new OidcExtension(OidcProvider.of("central", provider.issuer(), "app", "secret")).allowLocalProviders(); + assertThrows(IllegalArgumentException.class, () -> extension.register(OidcProvider.of("central", stranger.issuer(), "app", "secret"))); + } + + /** Registering a provider makes the server fetch URLs a customer chose: not on its own network, not in clear text. */ + @Test + void aRuntimeProviderOnAPrivateAddressIsRefused() { + assertThrows(IllegalArgumentException.class, () -> new OidcExtension().register(OidcProvider.of("evil", stranger.issuer(), "app", "secret"))); + } + + private static String cookie(List setCookies, String name) { + return setCookies.stream().filter(c -> c.startsWith(name + "=")).map(c -> c.substring(0, c.indexOf(';'))).findFirst().orElseThrow(); + } +} diff --git a/flash-extensions/flash-ext-security-test/docs/README.md b/flash-extensions/flash-ext-security-test/docs/README.md new file mode 100644 index 0000000..706ca1f --- /dev/null +++ b/flash-extensions/flash-ext-security-test/docs/README.md @@ -0,0 +1,15 @@ +# flash-ext-security-test + +Test-scope utilities for applications on `flash-ext-security-core`. + +```java +FlashTest app = FlashTest.of(flash -> flash.apply(new MyApp()).install(new TestSecurity())); + +app.request().with(TestSecurity.as(() -> "alice")).get("/me"); // any principal +app.request().with(TestSecurity.as(new OidcPrincipal(...))).get("/projects"); // a mechanism's own type +``` + +`TestSecurity.as(principal)` hands the principal to the application by reference — `FlashTest` +serves it in the same JVM — so tests exercise the real `UserResolver`, `RoleResolver` and policies +with no identity provider running. Tests of the flows themselves use the mechanism's own kit: +`FakeOidcProvider` and `OidcTokens` in this module for OIDC, a real `POST` for form login. diff --git a/flash-extensions/flash-ext-security-test/pom.xml b/flash-extensions/flash-ext-security-test/pom.xml new file mode 100644 index 0000000..e3a9515 --- /dev/null +++ b/flash-extensions/flash-ext-security-test/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-security-test + + + + dev.relism + flash-ext-security-core + + + dev.relism + flash-testing + compile + + + com.nimbusds + nimbus-jose-jwt + + + org.junit.jupiter + junit-jupiter + + + diff --git a/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/FakeOidcProvider.java b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/FakeOidcProvider.java new file mode 100644 index 0000000..c90d03b --- /dev/null +++ b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/FakeOidcProvider.java @@ -0,0 +1,150 @@ +package dev.relism.flash.ext.security.test; + +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.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jose.util.JSONObjectUtils; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import dev.relism.flash.testing.FlashRequest; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.net.URLEncoder; +import java.util.Set; +import java.util.Base64; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** + * An OpenID Provider on a random local port: discovery, keys, and a token endpoint for the + * authorization code and refresh flows. Sign-in is approved at once, as {@link #signInAs}. + */ +public final class FakeOidcProvider implements AutoCloseable { + + private final HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + private final RSAKey key = new RSAKeyGenerator(2048).keyID(UUID.randomUUID().toString()).generate(); + private final Map nonces = new ConcurrentHashMap<>(); + private final String issuer = "http://127.0.0.1:" + server.getAddress().getPort(); + private volatile String subject = "alice"; + private volatile Map claims = Map.of(); + private volatile long expiresIn = 300; + /** Null accepts any client, which is what every test that is not about client registration wants. */ + private volatile String clientSecret; + private volatile Set redirectUris = Set.of(); + + public FakeOidcProvider() throws Exception { + server.createContext("/.well-known/openid-configuration", ex -> send(ex, 200, JSONObjectUtils.toJSONString(Map.of( + "issuer", issuer, "authorization_endpoint", issuer + "/authorize", "token_endpoint", issuer + "/token", + "jwks_uri", issuer + "/jwks", "end_session_endpoint", issuer + "/logout")))); + server.createContext("/jwks", ex -> send(ex, 200, new JWKSet(key.toPublicJWK()).toString())); + server.createContext("/authorize", ex -> { + Map query = form(ex.getRequestURI().getRawQuery()); + if (!redirectUris.isEmpty() && !redirectUris.contains(query.get("redirect_uri"))) { + send(ex, 400, "Unregistered redirect_uri"); + return; + } + String code = UUID.randomUUID().toString(); + nonces.put(code, query.get("nonce")); + ex.getResponseHeaders().add("Location", query.get("redirect_uri") + "?code=" + code + "&state=" + query.get("state")); + send(ex, 302, ""); + }); + server.createContext("/token", ex -> { + String basic = ex.getRequestHeaders().getFirst("Authorization"); + if (clientSecret != null && (basic == null || !new String(Base64.getDecoder().decode(basic.substring(6)), StandardCharsets.UTF_8) + .endsWith(":" + URLEncoder.encode(clientSecret, StandardCharsets.UTF_8)))) { + send(ex, 401, "{\"error\":\"invalid_client\"}"); + return; + } + Map body = form(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + String nonce = nonces.remove(body.getOrDefault("code", "")); + if (body.get("grant_type").equals("authorization_code") && nonce == null) { + send(ex, 400, "{\"error\":\"invalid_grant\"}"); + return; + } + Map id = new HashMap<>(claims); + id.put("aud", "app"); + if (nonce != null) id.put("nonce", nonce); + send(ex, 200, JSONObjectUtils.toJSONString(Map.of("access_token", token(subject, claims), "id_token", token(subject, id), + "refresh_token", UUID.randomUUID().toString(), "expires_in", expiresIn, "scope", "openid email"))); + }); + server.start(); + } + + /** Refuses, from now on, any other secret or redirect URI — as a provider with this client registered would. */ + public FakeOidcProvider client(String secret, String... redirectUris) { + this.clientSecret = secret; + this.redirectUris = Set.of(redirectUris); + return this; + } + + public String issuer() { + return issuer; + } + + /** Who sign-in and refresh vouch for, with what extra claims; the client id is always {@code app}. */ + public FakeOidcProvider signInAs(String subject, Map claims) { + this.subject = subject; + this.claims = claims; + return this; + } + + /** Lifetime of the tokens sign-in and refresh issue — {@code 0} expires a session at once. */ + public FakeOidcProvider expiresIn(long seconds) { + this.expiresIn = seconds; + return this; + } + + /** A signed token for {@code subject}, valid five minutes, with {@code claims} added. */ + public String token(String subject, Map claims) { + try { + JWTClaimsSet.Builder set = new JWTClaimsSet.Builder().issuer(issuer).subject(subject) + .issueTime(new Date()).expirationTime(new Date(System.currentTimeMillis() + 300_000)); + claims.forEach(set::claim); + SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID()).build(), set.build()); + jwt.sign(new RSASSASigner(key)); + return jwt.serialize(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + /** Sends the request with a bearer {@link #token}. */ + public Consumer bearer(String subject, Map claims) { + String header = "Bearer " + token(subject, claims); + return request -> request.header("Authorization", header); + } + + @Override + public void close() { + server.stop(0); + } + + private static Map form(String encoded) { + Map fields = new HashMap<>(); + if (encoded != null) for (String pair : encoded.split("&")) { + int eq = pair.indexOf('='); + if (eq > 0) fields.put(pair.substring(0, eq), URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8)); + } + return fields; + } + + private static void send(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length); + if (bytes.length > 0) try (var out = exchange.getResponseBody()) { out.write(bytes); } + exchange.close(); + } +} diff --git a/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/OidcTokens.java b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/OidcTokens.java new file mode 100644 index 0000000..1ff2de9 --- /dev/null +++ b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/OidcTokens.java @@ -0,0 +1,47 @@ +package dev.relism.flash.ext.security.test; + +import com.nimbusds.jose.util.JSONObjectUtils; +import dev.relism.flash.testing.FlashRequest; + +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.function.Consumer; + +/** Real tokens from a real provider — Keycloak in Testcontainers, typically. */ +public final class OidcTokens { + + private static final HttpClient HTTP = HttpClient.newHttpClient(); + + private OidcTokens() {} + + /** + * Sends the request with an access token obtained by the resource owner password grant. The + * client must allow direct access grants — a test realm's setting, never a production one. + */ + public static Consumer passwordGrant(String issuer, String clientId, String clientSecret, String username, String password) { + try { + String discovery = issuer + (issuer.endsWith("/") ? "" : "/") + ".well-known/openid-configuration"; + String tokenEndpoint = (String) JSONObjectUtils.parse(get(HttpRequest.newBuilder(URI.create(discovery)))).get("token_endpoint"); + String form = "grant_type=password&scope=openid&client_id=" + encode(clientId) + "&client_secret=" + encode(clientSecret) + + "&username=" + encode(username) + "&password=" + encode(password); + String header = "Bearer " + JSONObjectUtils.parse(get(HttpRequest.newBuilder(URI.create(tokenEndpoint)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form)))).get("access_token"); + return request -> request.header("Authorization", header); + } catch (Exception e) { + throw new IllegalStateException("Could not obtain a token from " + issuer, e); + } + } + + private static String get(HttpRequest.Builder request) throws Exception { + return HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString()).body(); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/TestSecurity.java b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/TestSecurity.java new file mode 100644 index 0000000..4b453bd --- /dev/null +++ b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/TestSecurity.java @@ -0,0 +1,46 @@ +package dev.relism.flash.ext.security.test; + +import dev.relism.flash.ext.security.Principal; +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 dev.relism.flash.testing.FlashRequest; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** + * Authenticates test requests as any {@link Principal}, whatever mechanisms the application + * installs — an OIDC user, an API key, a plain name — without an identity provider. + * + *

{@code
+ * FlashTest app = FlashTest.of(flash -> flash.apply(new MyApp()).install(new TestSecurity()));
+ * app.request().with(TestSecurity.as(() -> "alice")).get("/me");
+ * }
+ * + *

Works because {@code FlashTest} serves the application in the test's own JVM: the principal + * is handed over by reference, under a token only this process can have issued. + */ +public final class TestSecurity implements FlashExtension { + + private static final String SCHEME = "Test "; + private static final Map PRINCIPALS = new ConcurrentHashMap<>(); + + /** Sends the request as {@code principal}. */ + public static Consumer as(Principal principal) { + String token = UUID.randomUUID().toString(); + PRINCIPALS.put(token, principal); + return request -> request.header("Authorization", SCHEME + token); + } + + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + ctx.onReady(() -> ctx.require(SecurityExtension.class).mechanism(req -> { + String header = req.header("Authorization"); + return header != null && header.startsWith(SCHEME) ? PRINCIPALS.get(header.substring(SCHEME.length())) : null; + })); + } +} diff --git a/flash-extensions/flash-ext-security-test/src/test/java/dev/relism/flash/ext/security/test/TestSecurityTest.java b/flash-extensions/flash-ext-security-test/src/test/java/dev/relism/flash/ext/security/test/TestSecurityTest.java new file mode 100644 index 0000000..0305524 --- /dev/null +++ b/flash-extensions/flash-ext-security-test/src/test/java/dev/relism/flash/ext/security/test/TestSecurityTest.java @@ -0,0 +1,29 @@ +package dev.relism.flash.ext.security.test; + +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; + +class TestSecurityTest { + + static final SecurityExtension security = new SecurityExtension(); + + @RegisterExtension + static final FlashTest app = FlashTest.of(flash -> flash + .install(security) + .install(new TestSecurity()) + .get("/me", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED))); + + @Test + void aRequestIsAuthenticatedAsTheGivenPrincipal() { + app.request().with(TestSecurity.as(() -> "alice")).get("/me").expectStatus(200).expectBody("alice"); + } + + @Test + void anUnknownTokenIsNobody() { + app.request().header("Authorization", "Test forged").get("/me").expectStatus(401); + } +} diff --git a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java index 356d222..bf5f3e5 100644 --- a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java +++ b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java @@ -15,6 +15,23 @@ import java.util.function.Function; public abstract class BaseViewExtension implements FlashExtension { private final List globals = new ArrayList<>(); + protected BaseViewExtension() {} + + /** + * Seeds this instance with globals carried over from a prior one. Subclasses whose + * fluent settings methods return a new instance (e.g. {@code JteExtension.templateRoot(...)}) + * must route through this constructor — otherwise {@link #addGlobal} calls made before + * such a method silently vanish, since the new instance would start with an empty list. + */ + protected BaseViewExtension(List seedGlobals) { + globals.addAll(seedGlobals); + } + + /** Snapshot of globals registered so far — for subclasses to carry over into a new instance. */ + protected final List globals() { + return List.copyOf(globals); + } + public BaseViewExtension addGlobal(String key, Function resolver) { String k = Objects.requireNonNull(key, "global key must not be null").trim(); if (k.isEmpty()) { @@ -34,7 +51,13 @@ public abstract class BaseViewExtension implements FlashExtension { @Override public void configure(FlashRegistrar app, FlashContext ctx) { ViewRuntimeBridge runtime = createRuntime(List.copyOf(globals)); - ctx.provide(ViewRuntimeBridge.class, runtime); + try { + ctx.provide(ViewRuntimeBridge.class, runtime); + } catch (IllegalStateException e) { + throw new IllegalStateException("Another flash-ext-view implementation is already installed in " + + "this FlashApp. Only one view engine (e.g. ThymeleafExtension or JteExtension, not both) " + + "can be active per app — install a single implementation.", e); + } ctx.addAnnotationProcessor(handlerClass -> { validateHandlerClass(handlerClass); return List.of(); diff --git a/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/flash/ext/view/core/BaseViewExtensionTest.java b/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/flash/ext/view/core/BaseViewExtensionTest.java new file mode 100644 index 0000000..0e669d3 --- /dev/null +++ b/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/flash/ext/view/core/BaseViewExtensionTest.java @@ -0,0 +1,40 @@ +package dev.relism.flash.ext.view.core; + +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.models.RequestHandler; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class BaseViewExtensionTest { + + private static final class FakeViewExtension extends BaseViewExtension { + @Override + protected ViewRuntimeBridge createRuntime(List globals) { + return new ViewRuntimeBridge<>() { + @Override public Object resolve(Class handlerClass) { return null; } + @Override public RenderedView render(BaseViewHandler handler, Object target, + dev.relism.flash.models.Request req, + dev.relism.flash.models.Response res) { return null; } + }; + } + + @Override + protected void validateHandlerClass(Class handlerClass) {} + } + + @Test + void configure_twoViewEngines_failsWithClearMessage() { + FlashContext ctx = new FlashContext(); + new FakeViewExtension().configure(null, ctx); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> new FakeViewExtension().configure(null, ctx)); + + assertTrue(ex.getMessage().contains("Only one view engine"), + "expected a clear cross-engine message, got: " + ex.getMessage()); + } +} diff --git a/flash-extensions/flash-ext-view-jte/README.md b/flash-extensions/flash-ext-view-jte/README.md index 3365142..bb0aba8 100644 --- a/flash-extensions/flash-ext-view-jte/README.md +++ b/flash-extensions/flash-ext-view-jte/README.md @@ -8,7 +8,7 @@ This module keeps jte semantics front and center: - `JteHandler` - `@Template` - `ViewModel` from `flash-ext-view-core` -- `global.*` reserved namespace +- globals merged flat into the model, one typed `@param` per global The extension mirrors `gg.jte.ContentType` into Flash HTTP content type: diff --git a/flash-extensions/flash-ext-view-jte/docs/architecture.md b/flash-extensions/flash-ext-view-jte/docs/architecture.md index 5acd87f..eda94c4 100644 --- a/flash-extensions/flash-ext-view-jte/docs/architecture.md +++ b/flash-extensions/flash-ext-view-jte/docs/architecture.md @@ -9,7 +9,7 @@ 2. **Request-time** - Handler builds local `ViewModel`. - - Runtime injects globals under reserved `global` namespace and merges local model. + - Runtime merges globals and local model into one flat parameter map (fails fast on key collision). - jte renders template into `StringOutput`. 3. **Static assets (optional, enabled by default)** diff --git a/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md b/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md index 08b4e82..affd85f 100644 --- a/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md +++ b/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md @@ -28,6 +28,21 @@ Register globals in extension setup: new JteExtension().addGlobal("appName", req -> "Flash") ``` -Globals are available under `global` namespace in templates. +Globals are merged **flat** into the model — same level as local `ViewModel` keys, not +under a `global.*` namespace. jte templates are statically typed, so each template declares +one `@param` per global it actually uses, named exactly like the global key: -`global` is reserved and cannot be used as local model key. +```jte +@param String appName + +${appName} +``` + +A template that doesn't declare `appName` simply never sees it — no need to declare every +global on every page, only the ones a given template actually uses. + +A local `ViewModel` key that collides with a registered global name fails fast with +`IllegalStateException` at render time, so a typo can't silently shadow a global. + +Every registered global is still resolved on **every** render regardless of whether the +target template declares it, so keep resolvers cheap (no blocking I/O, no heavy allocation). diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java index 832bac2..e36ea2e 100644 --- a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java +++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java @@ -29,31 +29,32 @@ public final class JteExtension extends BaseViewExtension { this.settings = builder.build(); } - private JteExtension(JteSettings settings) { + private JteExtension(JteSettings settings, List seedGlobals) { + super(seedGlobals); this.settings = settings; } public JteExtension templateRoot(String templateRoot) { - return new JteExtension(settings.toBuilder().templateRoot(templateRoot).build()); + return new JteExtension(settings.toBuilder().templateRoot(templateRoot).build(), globals()); } public JteExtension serveStatics(boolean serveStatics) { - return new JteExtension(settings.toBuilder().serveStatics(serveStatics).build()); + return new JteExtension(settings.toBuilder().serveStatics(serveStatics).build(), globals()); } public JteExtension staticPrefix(String staticPrefix) { - return new JteExtension(settings.toBuilder().staticPrefix(staticPrefix).build()); + return new JteExtension(settings.toBuilder().staticPrefix(staticPrefix).build(), globals()); } public JteExtension withStaticCors() { - return new JteExtension(settings.toBuilder().enableStaticCors(true).build()); + return new JteExtension(settings.toBuilder().enableStaticCors(true).build(), globals()); } public JteExtension staticCors(Consumer corsConfig) { JteSettings.Builder builder = settings.toBuilder(); builder.enableStaticCors(true); corsConfig.accept(builder); - return new JteExtension(builder.build()); + return new JteExtension(builder.build(), globals()); } @Override diff --git a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java index bb80188..9e1197e 100644 --- a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java +++ b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java @@ -1,6 +1,11 @@ package dev.relism.flash.ext.view.jte; +import dev.relism.flash.ext.view.core.RenderedView; import dev.relism.flash.ext.view.core.ViewModel; +import dev.relism.flash.ext.view.core.ViewRuntimeBridge; +import dev.relism.flash.ext.view.jte.model.HomePage; +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.http.ContentType; import dev.relism.flash.models.Request; import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.Response; @@ -89,6 +94,39 @@ class JteExtensionTest { assertEquals("https://cdn.example.com", settings.staticCorsAllowOrigin()); } + @Test + void addGlobal_survives_subsequent_settings_builder_calls() throws Exception { + // addGlobal() before a chained settings method (templateRoot/serveStatics/...) used to + // be silently dropped: those methods return a *new* JteExtension instance, and the + // globals list lived on the instance, not the settings being carried forward. + JteExtension ext = new JteExtension(cfg -> cfg.developmentMode(true)) + .addGlobal("appName", req -> "FlashLab") + .templateRoot("templates") + .serveStatics(false); + + TestRegistrar app = new TestRegistrar(); + FlashContext ctx = new FlashContext(); + ext.configure(app, ctx); + ctx.complete(); + + @SuppressWarnings("unchecked") + ViewRuntimeBridge runtime = (ViewRuntimeBridge) ctx.require(ViewRuntimeBridge.class); + + JteHandler handler = new JteHandler() { + @Override + public ViewModel render(Request req) { + return ViewModel.empty() + .with("page", new HomePage("Flash + jte", "elorc")) + .with("build", "dev"); + } + }; + JteTarget target = new JteTarget("pages/home.jte", ContentType.TEXT_HTML); + RenderedView out = runtime.render(handler, target, request("/", null, null, null), + new Response(200, ContentType.JSON)); + + assertTrue(out.body().contains("FlashLab"), "global registered before templateRoot() must survive"); + } + @Test void routes_register_static_wildcard_when_enabled() { JteExtension ext = new JteExtension(cfg -> cfg.staticPrefix("/assets")); diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index aaa38c3..62ed536 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -16,7 +16,11 @@ flash-ext-jackson flash-ext-openapi - flash-ext-oidc + flash-ext-security-core + flash-ext-security-oidc + flash-ext-security-apikey + flash-ext-security-form + flash-ext-security-test flash-ext-routeviewer flash-ext-view-core flash-ext-view-jte @@ -45,6 +49,11 @@ flash-ext-scheduler ${project.version} + + dev.relism + flash-ext-security-core + ${project.version} + dev.relism flash-ext-cache-core @@ -60,6 +69,12 @@ caffeine ${caffeine.version} + + dev.relism + flash-ext-security-test + ${project.version} + test + dev.relism flash-testing diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java index b55b01b..51571d6 100644 --- a/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java @@ -7,6 +7,7 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Objects; +import java.util.function.Consumer; /** * A request being built against a {@link FlashTest} server. The HTTP verb is terminal — it @@ -41,6 +42,12 @@ public final class FlashRequest { return this; } + /** Applies {@code customizer} — how an extension's test kit attaches a credential, for instance. */ + public FlashRequest with(Consumer customizer) { + customizer.accept(this); + return this; + } + /** Sets a UTF-8 request body. */ public FlashRequest body(String text) { this.body = Objects.requireNonNull(text, "text").getBytes(StandardCharsets.UTF_8); diff --git a/flash/src/main/java/dev/relism/flash/exceptions/HttpException.java b/flash/src/main/java/dev/relism/flash/exceptions/HttpException.java index a7084b5..9dda050 100644 --- a/flash/src/main/java/dev/relism/flash/exceptions/HttpException.java +++ b/flash/src/main/java/dev/relism/flash/exceptions/HttpException.java @@ -32,7 +32,12 @@ public class HttpException extends RuntimeException { } public static HttpException forbidden() { - return new HttpException(403, "Forbidden"); + return forbidden("Forbidden"); + } + + /** Refused with a reason the caller may be told — why the credential is not enough, never what it would take. */ + public static HttpException forbidden(String message) { + return new HttpException(403, message); } public static HttpException notFound(String what) { diff --git a/flash/src/main/java/dev/relism/flash/models/Request.java b/flash/src/main/java/dev/relism/flash/models/Request.java index b7b0f03..9c45280 100644 --- a/flash/src/main/java/dev/relism/flash/models/Request.java +++ b/flash/src/main/java/dev/relism/flash/models/Request.java @@ -194,6 +194,35 @@ public class Request { */ public List headers() { checkActive(); return requestLine.getHeaders().all(); } + /** + * The scheme and authority the client addressed, e.g. {@code https://example.com}: from + * {@code X-Forwarded-Proto}/{@code X-Forwarded-Host} when present, otherwise the connection and + * the {@code Host} header. Only meaningful behind a proxy that sets or strips those headers. + */ + public String origin() { + String proto = header("X-Forwarded-Proto"); + String host = header("X-Forwarded-Host"); + return (proto != null ? proto : isSecure() ? "https" : "http") + "://" + (host != null ? host : header("Host")); + } + + /** + * Returns the value of cookie {@code name}, or {@code null} if the request does not carry it. + * Reads the {@code Cookie} header in place; only the returned value is allocated. + */ + public String cookie(String name) { + String cookies = header("Cookie"); + if (cookies == null) return null; + for (int at = cookies.indexOf(name); at >= 0; at = cookies.indexOf(name, at + 1)) { + int eq = at + name.length(); + if (eq < cookies.length() && cookies.charAt(eq) == '=' + && (at == 0 || cookies.charAt(at - 1) == ' ' || cookies.charAt(at - 1) == ';')) { + int end = cookies.indexOf(';', eq + 1); + return cookies.substring(eq + 1, end < 0 ? cookies.length() : end); + } + } + return null; + } + // ── Path parameters ─────────────────────────────────────────────────────── /** diff --git a/pom.xml b/pom.xml index 3f35318..d5546cc 100644 --- a/pom.xml +++ b/pom.xml @@ -106,7 +106,7 @@ dev.relism - flash-ext-oidc + flash-ext-security-oidc ${project.version}