37 Commits
Author SHA1 Message Date
Relism 5ece97ca7b Merge pull request 'fix(ext-openapi): one shared answer per status, not a numbered family' (#24) from fix/openapi/one-shared-answer-per-status into master
Publish Maven packages / publish (push) Successful in 3m36s
2026-09-23 16:34:15 +00:00
Zakaria El OrcheandClaude Opus 5 5fe6fb46c8 fix(ext-openapi): one shared answer per status, not a numbered family
Hoisting named a shared response after its status and disambiguated with a
counter, so a document with three wordings for 403 grew Forbidden, Forbidden2
and Forbidden3 in its components. Numbered names say nothing and move as soon
as a route is added.

The answer a status is usually given is now the one hoisted, under that
status's own name, and a route that answers the same status differently keeps
its wording inline where it belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:34:13 +00:00
Relism c0c9480fa5 Merge pull request 'feat(ext-openapi): a route can say it is not part of the API' (#23) from feat/openapi/undocumented into master
Publish Maven packages / publish (push) Successful in 2m56s
2026-09-23 15:47:37 +00:00
Zakaria El OrcheandClaude Opus 5 9090ba59f5 feat(ext-openapi): a route can say it is not part of the API
Every class-based route is documented, which is what keeps a document from
lying by omission. Some routes are not API at all — a health check, an
internal callback, something on its way out — and @Undocumented says so, once,
where the handler is. Inherited, so a base class leaves out every handler
written against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:47:35 +00:00
Relism 6a6431c528 Merge pull request 'fix(ext-jackson): a constraint says what it wants in its own words' (#22) from fix/validation/constraint-messages into master
Publish Maven packages / publish (push) Successful in 2m27s
2026-09-23 14:51:28 +00:00
Zakaria El OrcheandClaude Opus 5 491553e9f5 fix(ext-jackson): a constraint says what it wants in its own words
The compiler ignored the message a constraint declares and always wrote its
own, so a failed @Pattern answered the caller with a regex. It now uses the
annotation's message whenever one is set, and keeps the plain description for
jakarta's default, which is a resource bundle key and not something to put in
front of whoever sent the request.

This is what makes moving a check out of a service and onto the type it
belongs to cost nothing: the wording moves with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 14:51:16 +00:00
Relism dd0455c6d1 Merge pull request 'fix(ext-openapi): an answer's media type is the handler's, not the body's' (#21) from fix/openapi/answer-media-type into master
Publish Maven packages / publish (push) Successful in 2m29s
2026-09-23 14:42:14 +00:00
Zakaria El OrcheandClaude Opus 5 60dd4eab6a fix(ext-openapi): an answer's media type is the handler's, not the body's
@Consumes says what a route reads. It was also deciding what the document said
a route answers, through a JSON default nothing could override: a handler that
takes a JSON body is not thereby a handler that answers JSON.

@Produces now says that, beside @Consumes and as descriptive as it is — on the
handler, or once on a base class. Every response takes its media type from it,
JSON when nothing declares one, and the error object stays JSON because that is
what Flash answers a failure with whatever the route produces.

Content loses contentType with it. One handler answers in one format and a
status code does not change that, so the media type was in the wrong place; a
response with no schema and no return type to infer one from is a response
with no body, which is what a 204 was using it to say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 14:41:56 +00:00
Relism 2f06ca7c1d Merge pull request 'feat: typed bodies, injected services, one Jackson module per format' (#20) from feature/handlers/typed-bodies-and-injected-services into master
Publish Maven packages / publish (push) Successful in 2m31s
2026-09-23 14:11:17 +00:00
Zakaria El OrcheandClaude Opus 5 2fbe65fcc5 feat(ext-openapi): document what the code already says
Every class-based route is documented now, annotated or not: a route without
@ApiOperation used to be dropped with a warning, which made the document lie by
omission.

Read off the handler: the request body from the type it declares (or from the
new @RequestBody, for one that reads the body itself), the success schema from
the most specific handle it implements, and the media type from @Consumes.

Failures are described too. Any 4xx or 5xx without an explicit schema documents
the error object Flash actually answers with, written once under
components.schemas.Error — before this, a declared 4xx inherited the success
schema, which was simply wrong. And an answer two or more operations give
identically is hoisted into components.responses and referenced, so the 401 of
every guarded route appears once rather than on every path.

@Content gained an example, and the schema registry moved into its own class.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:31:28 +00:00
Zakaria El OrcheandClaude Opus 5 adcd6376b6 feat(ext-jackson): one module per data format, bodies checked on the way in
flash-ext-jackson and flash-ext-validation become three modules:

- flash-ext-jackson-core: the Codec (one mapper, body/write/writeView),
  JacksonHandler, the outbound marshalling, and the constraint engine that
  used to be flash-ext-validation
- flash-ext-jackson-json: Json, JsonExtension, JsonHandler
- flash-ext-jackson-xml: Xml, XmlExtension, XmlHandler

Every Jackson format is the same databind model behind a different factory, so
the annotations, the constraints and the published schema are the same for all
of them: only the mapper and the content type differ, and that is all a format
module says. A route picks its format by the handler it extends — there is no
negotiation and nothing to configure.

A typed body is now always verified against its own type's jakarta
constraints, whatever the format: malformed is a 400, a broken constraint is a
422, and neither reaches the handler. The validator was already allocation-free
and stays so; validating is no longer something an application remembers to do.

bodyFrom is gone. body has the streaming semantics, because the request's
stream is reused per connection while bytes() allocates the whole body: one
name, the path that does not allocate. JacksonExtension is JsonExtension, and
autoJson() is auto().

The root POM now manages every module of this build, so anything composing
Flash imports it once and never names a version again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:31:28 +00:00
Zakaria El OrcheandClaude Opus 5 ef4740f26d feat(core): a service a handler asks for, and a body typed in its signature
Two things every handler was writing by hand.

@Inject on a field is filled inside bind, before onInit, once per handler at
boot: the request path still reads a field. The service is looked up by the
field's exact declared type; a static or final field is refused, and a type
nothing provides fails the boot naming the field. onInit stays for what has to
be computed, or for a service that may not be there.

BodyHandler<B> puts the body type in the signature — handle(req, res, body) —
and leaves reading it to the format. bodyTypeOf resolves that type argument
through a whole chain of bases, so tooling can read off a class what a route
takes. @Consumes says in which media type, inherited from the base class that
implements the reading, and is descriptive: the router does not enforce it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:31:13 +00:00
Relism 8353033cb1 Merge pull request 'feat: Vite extension and Maven plugin; data pools close on stop' (#19) from feature/ext-vite/replace-web-bundler into master
Publish Maven packages / publish (push) Successful in 2m49s
2026-09-22 16:27:46 +00:00
Zakaria El Orche fd96d67fca Merge branch 'feature/ext-data/close-pool-on-stop' into feature/ext-vite/replace-web-bundler 2026-09-22 16:25:16 +00:00
Zakaria El OrcheandClaude Opus 5 003fd6d1f0 feat(ext-vite): recognise navigations by Sec-Fetch-Mode, read every header in place
A path that is no file falls back to index.html when the request is a navigation:
Sec-Fetch-Mode: navigate, or an Accept naming text/html for older clients. That check,
Accept-Encoding and If-None-Match are all matched on the header bytes through the new
Request.headerView(name), so serving still allocates nothing. navigationOnly(false)
drops the check for an app that wants every GET miss to get the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:16:27 +00:00
Zakaria El OrcheandClaude Opus 5 680bbca8c7 feat(ext-vite): gzip at build time, fall back to the index only for navigations
The Maven plugin now writes a maximally compressed .gz beside every text file of 1 KB
and more, so the server compresses nothing and boot only reads the files: about 50 ms
for Glossa's 500. Hashed files under assets/ skip the ETag, which nothing ever asks for.

A path that is no file gets index.html only when the request's Accept names text/html,
as a browser navigation does. Everything else, an API call to a missing route included,
gets the app's own 404 instead of the index, and a dot in a client route no longer
matters. The base path itself always serves the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:54:19 +00:00
Zakaria El OrcheandClaude Opus 5 580417e952 feat(ext-vite): replace the web bundler with a Vite extension and its Maven plugin
flash-ext-vite runs Vite's dev server in DEV and otherwise serves the build from the
classpath, read straight from the directory or jar with no manifest. The Maven plugin
flash-ext-vite-maven-plugin builds the frontend at prepare-package and packages it
there, so mvn package makes a jar that serves its own frontend and mvn test needs no
Node. Three overrides remain (root, devPort, basePath); the package manager is read
from the nearest lockfile.

Serving fixes what the bundler got wrong: Vite's hashed files under assets/ are
cached as immutable instead of revalidated, HEAD reports the real Content-Length, a
missing asset is a 404 instead of the index, 304s carry ETag and Cache-Control, and
gzip respects q=0 and is prepared at boot. Every response header is pre-encoded, so
serving allocates nothing, which is what Response.type(byte[]) is for. Vite stops
with the app through onClose, and a lockfile change reinstalls before restarting.

The modes, strategies, logging and command-safety options, the asset-source
abstraction, the manifest and the Jackson dependency are gone: 1,535 lines of main
code become 480, plus 84 for the plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:47:28 +00:00
Zakaria El OrcheandClaude Opus 5 d6c018242f feat(ext-data): close the connection pool when the app stops
TxManager is AutoCloseable and releases what it was built on: JdbcTxManager its data
source when closeable, HibernateTxManager its session factory and then the data source
Hibernate was handed, which Hibernate itself never closes. DataExtension registers the
close as an onClose callback, so a stopped app no longer leaves its pool connected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:02:35 +00:00
Relism 6d44f9e7b1 Merge pull request 'feat(ext-security): add an OAuth 2.1 authorization server' (#18) from feature/ext-security/oauth-server into master
Publish Maven packages / publish (push) Successful in 2m18s
2026-09-22 11:42:26 +00:00
Zakaria El OrcheandClaude Opus 5 f28fc43150 feat(ext-security): add an OAuth 2.1 authorization server
flash-ext-security-oauth-server issues RFC 9068 access tokens (code + PKCE S256,
CIMD and DCR clients, RFC 8707 resources, rotating refresh tokens) for resources on
the application's own origin. Around it: SecurityExtension resolves a configured
origin instead of X-Forwarded-* headers, mechanisms expose schemes() and a route can
be restricted to some of them, McpConfig.mechanisms(...) uses that, OIDC bearers must
be typed at+jwt, and PublicUrl guards outbound fetches against internal addresses.

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

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

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

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

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

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

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

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

Breaking for consumers: imports move to dev.relism.flash.ext.auth, OidcMiddleware
becomes AuthMiddleware, ClaimsHolder.user()/get() become current()/map().
2026-09-10 19:00:39 +00:00
Zakaria El Orche ea00182c7c test(ext-oidc): characterise claim matching before the auth-core split
Pins the current behaviour of the role/scope matching that is about to move out
of OidcMiddleware: delimiter set for string claims, whole-entry comparison for
list claims, trimming, empty-requirement semantics under ALL vs ANY, and how a
claim path that walks into a non-map resolves. None of it is OIDC-specific and
none of it was covered directly.
2026-09-10 18:48:10 +00:00
Relism 9892ad44b7 Merge pull request 'feat(ext-scheduler): add interval and cron background jobs' (#16) from feature/ext-scheduler/cron-and-interval-jobs into master
Publish Maven packages / publish (push) Successful in 2m10s
2026-09-09 14:36:36 +00:00
Zakaria El OrcheandClaude Sonnet 5 4866624e73 Merge branch 'master' into feature/ext-scheduler/cron-and-interval-jobs
Resolve conflicts in AGENTS.md, pom.xml and flash-extensions/pom.xml — master
had already picked up ext-validation and ext-cache-core/caffeine (merged after
this branch was cut); union kept alongside this branch's ext-scheduler entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 14:35:18 +00:00
Relism d3fe740e05 Merge pull request 'Feature/ext cache/core and caffeine' (#14) from feature/ext-cache/core-and-caffeine into master
Publish Maven packages / publish (push) Successful in 2m16s
2026-09-09 14:29:29 +00:00
Zakaria El OrcheandClaude Sonnet 5 4feabc45d5 fix(ext-view): clear cross-engine error, propagate globals across JteExtension builder chain
- BaseViewExtension: wrap the ViewRuntimeBridge provider collision with a message
  naming the real constraint (one view engine per FlashApp) instead of the generic
  "duplicate provider" error.
- BaseViewExtension/JteExtension: carry registered globals across JteExtension's
  immutable settings builders (templateRoot/serveStatics/staticPrefix/withStaticCors/
  staticCors). addGlobal() called before any of those used to be silently dropped,
  since each builder method returned a fresh instance with an empty globals list.
- Correct flash-ext-view-jte docs (README, architecture.md, model-and-globals.md):
  globals merge flat with one typed @param per key, not under a global.* namespace
  like Thymeleaf — the docs previously claimed the same reserved namespace for both
  engines, which doesn't match JteRuntime's actual merge behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 13:31:21 +00:00
Zakaria El Orche 9de2800a83 Merge branch 'feature/ext-cache/core-and-caffeine' into feature/extensions/validation-scheduler-cache
# Conflicts:
#	AGENTS.md
#	flash-extensions/pom.xml
#	pom.xml
2026-09-09 12:37:52 +00:00
Zakaria El Orche dbf057f493 Merge branch 'feature/ext-scheduler/cron-and-interval-jobs' into feature/extensions/validation-scheduler-cache
# Conflicts:
#	AGENTS.md
#	flash-extensions/pom.xml
#	pom.xml
2026-09-09 12:37:26 +00:00
Zakaria El OrcheandClaude Opus 5 5c163b7f8d feat(ext-scheduler): add interval and cron background jobs
jobs.every(Duration.ofMinutes(5), reports::refresh);
    jobs.cron("0 0 3 * * *", archive::sweep);

One daemon platform thread keeps time; every job body runs on a virtual thread,
so a slow job delays nothing but its own next run and cannot occupy the timer.

Cron expressions compile once into a bitmask per field — a long for seconds and
minutes, an int for the rest — so matching an instant is a shift and a mask
rather than a parse or a set lookup. Next-fire advances by the largest unit
that cannot match instead of ticking second by second, so a yearly expression
resolves in a few dozen iterations rather than thirty million. Five or six
fields, ranges, steps, lists, named months and weekdays, both Sunday encodings,
and the standard union semantics when both day fields are restricted. A
malformed expression throws when the job is registered, not when it would have
fired.

Overlapping runs are skipped, and deliberately not configurable: two copies of
one job at once is a bug in every case anyone has needed, and a flag would only
let it be set wrongly. A skipped run logs how long the previous one has been
going.

A throwing job is logged and keeps its schedule. scheduleAtFixedRate cancels
the task on first exception, silently — a job that dies at 3am and is never
heard from again is the failure this avoids.

Shutdown goes through FlashContext.onClose, so app.stop() drains HTTP first,
then gives running jobs a grace period before forcing them down. Nothing runs
after the app stops. The grace period is the only knob.

Known ceiling, marked in the source: schedules are per-instance, so two
replicas run every job twice. A distributed lock should not exist until there
is a second replica.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:23:34 +00:00
283 changed files with 9406 additions and 7772 deletions
+4 -4
View File
@@ -17,8 +17,8 @@
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-security-oidc/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-security-oidc/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
@@ -31,8 +31,8 @@
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-vite/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-vite/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
+2 -2
View File
@@ -37,8 +37,8 @@ Format: `<type>(<scope>): <short description>`
| `ci` | Changes to GitHub Actions workflows | | `ci` | Changes to GitHub Actions workflows |
Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`, Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`, `ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-vite`,
`ext-mcp`, `ext-validation`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `ext-mcp`, `ext-validation`, `ext-scheduler`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`,
`ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`. `ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`.
Examples: Examples:
+20 -7
View File
@@ -9,14 +9,22 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
|---|---| |---|---|
| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model | | `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses | | `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration | | `flash-extensions/flash-ext-jackson-core` | What every Jackson format shares: the codec, the body handler, the constraints a body is checked against |
| `flash-extensions/flash-ext-jackson-json` | JSON bodies and responses |
| `flash-extensions/flash-ext-jackson-xml` | XML bodies and responses |
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI | | `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow | | `flash-extensions/flash-ext-security-core` | Security: authentication chain, annotations, sessions, OpenAPI |
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-oidc | | `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
| `flash-extensions/flash-ext-security-apikey` | API keys |
| `flash-extensions/flash-ext-security-form` | Password sign-in |
| `flash-extensions/flash-ext-security-oauth-server` | OAuth 2.1 authorization server for the application's own users and resources |
| `flash-extensions/flash-ext-security-test` | Test identities, fake OpenID Provider |
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, secured by flash-ext-security-core |
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives | | `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension | | `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension | | `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
| `flash-extensions/flash-ext-validation` | Request validation — jakarta constraints, compiled once per type | | `flash-extensions/flash-ext-vite` | Vite frontend: dev server in DEV, the built SPA from the jar otherwise |
| `flash-extensions/flash-ext-vite-maven-plugin` | Builds the Vite frontend into the jar during `mvn package` |
| `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads | | `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads |
| `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` | | `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` |
| `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine | | `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine |
@@ -144,13 +152,18 @@ FlashApp.create(8080)
``` ```
See extension-specific READMEs for full details: See extension-specific READMEs for full details:
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md) - [`flash-ext-jackson-core`](flash-extensions/flash-ext-jackson-core/README.md)
- [`flash-ext-jackson-json`](flash-extensions/flash-ext-jackson-json/README.md)
- [`flash-ext-jackson-xml`](flash-extensions/flash-ext-jackson-xml/README.md)
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md) - [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md) - [`flash-ext-security-core`](flash-extensions/flash-ext-security-core/docs/README.md)
- [`flash-ext-security-oidc`](flash-extensions/flash-ext-security-oidc/docs/README.md)
- [`flash-ext-security-apikey`](flash-extensions/flash-ext-security-apikey/docs/README.md)
- [`flash-ext-security-form`](flash-extensions/flash-ext-security-form/docs/README.md)
- [`flash-ext-security-test`](flash-extensions/flash-ext-security-test/docs/README.md)
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md) - [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md) - [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md) - [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
- [`flash-ext-validation`](flash-extensions/flash-ext-validation/docs/README.md)
- [`flash-ext-scheduler`](flash-extensions/flash-ext-scheduler/docs/README.md) - [`flash-ext-scheduler`](flash-extensions/flash-ext-scheduler/docs/README.md)
- [`flash-ext-cache-caffeine`](flash-extensions/flash-ext-cache-caffeine/docs/README.md) - [`flash-ext-cache-caffeine`](flash-extensions/flash-ext-cache-caffeine/docs/README.md)
- [`flash-testing`](flash-testing/docs/README.md) - [`flash-testing`](flash-testing/docs/README.md)
@@ -112,6 +112,8 @@ public abstract class Repository<T, ID> {
- `Tx` in the `FlashContext` - `Tx` in the `FlashContext`
- `TxManager` in the `FlashContext` - `TxManager` in the `FlashContext`
- an annotation processor for `@Transactional` - an annotation processor for `@Transactional`
- `TxManager.close()` as an `onClose` callback, so stopping the app releases the manager's
session factory and connection pool; give the manager a pool you want closed with the app
This makes the data layer composable with Flash's extension system without global state. This makes the data layer composable with Flash's extension system without global state.
@@ -34,6 +34,7 @@ public final class DataExtension implements FlashExtension {
@Override @Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) { public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.onClose(txManager::close);
ctx.provide(Tx.class, tx); ctx.provide(Tx.class, tx);
ctx.provide(TxManager.class, txManager); ctx.provide(TxManager.class, txManager);
if (data != null) ctx.provide(Data.class, data); if (data != null) ctx.provide(Data.class, data);
@@ -1,7 +1,11 @@
package dev.relism.flash.ext.data.core; package dev.relism.flash.ext.data.core;
public interface TxManager { public interface TxManager extends AutoCloseable {
TxStatus begin(TxDefinition definition); TxStatus begin(TxDefinition definition);
void commit(TxStatus status); void commit(TxStatus status);
void rollback(TxStatus status); void rollback(TxStatus status);
/** Releases what this manager was built on, its connection pool included. {@link dev.relism.flash.ext.data.DataExtension} calls it when the app stops. */
@Override
void close();
} }
@@ -3,6 +3,10 @@ package dev.relism.flash.ext.data.hibernate;
import dev.relism.flash.ext.data.core.*; import dev.relism.flash.ext.data.core.*;
import org.hibernate.Session; import org.hibernate.Session;
import org.hibernate.SessionFactory; import org.hibernate.SessionFactory;
import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import javax.sql.DataSource;
import java.util.Objects; import java.util.Objects;
@@ -16,6 +20,24 @@ public class HibernateTxManager implements TxManager {
this.sf = Objects.requireNonNull(sessionFactory); this.sf = Objects.requireNonNull(sessionFactory);
} }
/**
* Closes the session factory, then the data source it was given ({@code jakarta.persistence.nonJtaDataSource}
* or {@code hibernate.connection.datasource}): Hibernate stops a pool it built itself, never one handed to it.
*/
@Override
public void close() {
ConnectionProvider connections = sf.unwrap(SessionFactoryImplementor.class).getServiceRegistry().getService(ConnectionProvider.class);
DataSource ds = connections != null && connections.isUnwrappableAs(DataSource.class) ? connections.unwrap(DataSource.class) : null;
sf.close();
if (ds instanceof AutoCloseable closeable) {
try {
closeable.close();
} catch (Exception e) {
throw new IllegalStateException("Failed to close the data source", e);
}
}
}
@Override @Override
public TxStatus begin(TxDefinition definition) { public TxStatus begin(TxDefinition definition) {
return switch (definition.propagation()) { return switch (definition.propagation()) {
@@ -0,0 +1,48 @@
package dev.relism.flash.ext.data.hibernate;
import org.hibernate.SessionFactory;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.hibernate.cfg.AvailableSettings;
import org.junit.jupiter.api.Test;
import javax.sql.DataSource;
import java.io.PrintWriter;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.logging.Logger;
import static org.junit.jupiter.api.Assertions.assertTrue;
class HibernateTxManagerCloseTest {
/** Stands in for a pool: closeable, and it records being closed. */
static final class Pool implements DataSource, AutoCloseable {
boolean closed;
@Override public Connection getConnection() throws SQLException { return DriverManager.getConnection("jdbc:h2:mem:tx-close;DB_CLOSE_DELAY=-1"); }
@Override public Connection getConnection(String user, String password) throws SQLException { return getConnection(); }
@Override public void close() { closed = true; }
@Override public <T> T unwrap(Class<T> type) { throw new UnsupportedOperationException(); }
@Override public boolean isWrapperFor(Class<?> type) { return false; }
@Override public PrintWriter getLogWriter() { return null; }
@Override public void setLogWriter(PrintWriter out) {}
@Override public void setLoginTimeout(int seconds) {}
@Override public int getLoginTimeout() { return 0; }
@Override public Logger getParentLogger() { throw new UnsupportedOperationException(); }
}
@Test
void closingTheManagerClosesTheDataSourceHibernateWasGiven() {
Pool pool = new Pool();
SessionFactory sf = new MetadataSources(new StandardServiceRegistryBuilder()
.applySetting(AvailableSettings.JAKARTA_NON_JTA_DATASOURCE, pool)
.applySetting(AvailableSettings.DIALECT, "org.hibernate.dialect.H2Dialect")
.build()).buildMetadata().buildSessionFactory();
new HibernateTxManager(sf).close();
assertTrue(sf.isClosed());
assertTrue(pool.closed);
}
}
@@ -17,6 +17,18 @@ public class JdbcTxManager implements TxManager {
this.ds = Objects.requireNonNull(ds); this.ds = Objects.requireNonNull(ds);
} }
/** Closes the data source when it is closeable, as a pool is; a plain {@code DataSource} holds nothing to release. */
@Override
public void close() {
if (ds instanceof AutoCloseable closeable) {
try {
closeable.close();
} catch (Exception e) {
throw new IllegalStateException("Failed to close the data source", e);
}
}
}
@Override @Override
public TxStatus begin(TxDefinition definition) { public TxStatus begin(TxDefinition definition) {
return switch (definition.propagation()) { return switch (definition.propagation()) {
@@ -0,0 +1,20 @@
package dev.relism.flash.ext.data.jdbc;
import com.zaxxer.hikari.HikariDataSource;
import dev.relism.flash.ext.data.DataExtension;
import dev.relism.flash.extension.FlashApp;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JdbcTxManagerCloseTest {
/** A pool outlives its app unless someone closes it; the data extension does, once requests have drained. */
@Test
void stoppingTheAppClosesThePool() {
HikariDataSource pool = new HikariDataSource();
pool.setJdbcUrl(TestDataSource.URL);
FlashApp.create(0).install(new DataExtension(new JdbcTxManager(pool))).start().stop().join();
assertTrue(pool.isClosed());
}
}
@@ -0,0 +1,78 @@
# flash-ext-jackson-core
What every Jackson data format shares. Applications do not install this module directly: they
install a format — [`flash-ext-jackson-json`](../flash-ext-jackson-json),
[`flash-ext-jackson-xml`](../flash-ext-jackson-xml) — and get all of this with it.
## Why there is a core at all
Every Jackson data format is the same databind model behind a different factory: `XmlMapper`,
`YAMLMapper` and `CBORMapper` are all `ObjectMapper`s. So the annotations on a type, the
constraints its fields declare and the schema it publishes are the same whatever writes it. Only
the mapper and the content type differ, and that is all a format module has to say.
## Codec
One mapper, in the shape a handler needs it.
| | |
|---|---|
| `body(Request, Class<T>)` | Parses the body and verifies its constraints |
| `write(Response, Object)` | Serializes and sets the format's content type |
| `writeView(Response, Object, Class<?>)` | The same, through a Jackson `@JsonView` |
| `mapper()` | The `ObjectMapper` itself, for everything else |
`body` reads straight off the request's stream, which Flash reuses per connection: the body is
never buffered into an array to be handed over. A malformed body is a 400, a body that breaks a
constraint is a 422, and neither reaches the handler.
## Constraints
A body is checked against the `jakarta.validation` annotations its own type declares — nothing to
install, nothing to call:
```java
public record NewUser(@NotBlank @Size(max = 80) String name, @Email String email, @Min(18) int age) {}
```
Supported: `@NotNull`, `@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`.
Jakarta semantics: only `@NotNull` rejects null, every other constraint passes it.
A failure reads `<field> <message>`, and the message is the constraint's own when it sets one —
which is how the person who sent the request is told something better than a regex:
```java
@Pattern(regexp = "[A-Za-z0-9_][A-Za-z0-9_.-]{0,254}", message = "uses up to 255 letters, digits, _, . and -")
String key
```
```json
{"error": "key uses up to 255 letters, digits, _, . and -", "status": 422}
```
The constraints of a type are compiled the first time it is seen and kept in a `ClassValue`,
beside the class itself — no map, no lock. A check reads the field through an exact-signature
`MethodHandle`: no boxing, no argument array, no iterator, and nothing allocated at all unless
something fails. A type that declares no constraints compiles to a validator that does nothing.
Verify a value built by hand with `Validator.check(value)`.
`flash-ext-openapi` reads the same annotations to publish `minLength`, `maximum`, `pattern` and
the required fields, so a rule is written once and both enforced and documented.
## Writing a format module
```java
public final class Yaml extends Codec {
public Yaml(YAMLMapper mapper) { super(mapper, ContentType.TEXT_YAML); }
}
@Consumes(ContentType.TEXT_YAML)
public abstract class YamlHandler<B> extends JacksonHandler<B> {
@Inject private Yaml yaml;
@Override protected Codec codec() { return yaml; }
}
```
Plus an extension that provides the codec and, for outbound bodies,
`Marshalling.of(mapper, contentType)`. That is the whole of it.
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-jackson-core</artifactId>
<name>flash-ext-jackson-core</name>
<description>What every Jackson data format shares: the codec, the body handler and the constraints a body is checked against.</description>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<!-- The constraint annotations a body is checked against; no implementation, no transitives. -->
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.validation; package dev.relism.flash.ext.jackson;
import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandle;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -0,0 +1,72 @@
package dev.relism.flash.ext.jackson;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
/**
* One Jackson mapper, in the shape a handler needs it.
*
* <p>Every Jackson data format is the same databind model behind a different factory, so this is
* the whole of what a format module has to say: which mapper, and which content type it writes.
* What the annotations mean, what a body is checked against and how a type is described are the
* same for all of them.
*
* <p>Retrieve it once at boot — {@code @Inject private Json json;} — and call it on the hot path.
* The underlying {@link ObjectMapper} is thread-safe once configured.
*/
public abstract class Codec {
private final ObjectMapper mapper;
private final ContentType contentType;
protected Codec(ObjectMapper mapper, ContentType contentType) {
this.mapper = mapper;
this.contentType = contentType;
}
/**
* Reads the request body as {@code type} and verifies its constraints.
*
* <p>Read straight off the request's stream, which Flash reuses per connection: nothing
* buffers the body to hand it over. A type that declares no constraints is not checked at all.
*
* @throws HttpException 400 if the body cannot be parsed as {@code type}
* @throws ValidationException 422 if it parses but violates a constraint
*/
public <T> T body(Request request, Class<T> type) throws Exception {
T value;
try {
value = mapper.readValue(request.body().stream(), type);
} catch (JsonProcessingException malformed) {
throw HttpException.badRequest("Invalid request body: " + malformed.getOriginalMessage());
}
Validator.of(type).verify(value);
return value;
}
/** Serializes {@code value} and sets this codec's content type on the response. */
public String write(Response response, Object value) throws Exception {
response.type(contentType);
return mapper.writeValueAsString(value);
}
/** Like {@link #write}, restricted to the fields visible under a Jackson {@code @JsonView}. */
public String writeView(Response response, Object value, Class<?> view) throws Exception {
response.type(contentType);
return mapper.writerWithView(view).writeValueAsString(value);
}
/** What this codec writes, for a handler that sets the response type itself. */
public ContentType contentType() {
return contentType;
}
/** The mapper itself, for everything these methods do not cover. */
public ObjectMapper mapper() {
return mapper;
}
}
@@ -0,0 +1,33 @@
package dev.relism.flash.ext.jackson;
import dev.relism.flash.models.BodyHandler;
import dev.relism.flash.models.Request;
/**
* A handler whose body one Jackson format parses and whose constraints are checked before it
* arrives.
*
* <p>Format modules extend this and name their codec — {@code JsonHandler}, {@code XmlHandler}.
* An application extends those, never this one.
*
* @param <B> the body type, which is also what the published OpenAPI document describes
*/
public abstract class JacksonHandler<B> extends BodyHandler<B> {
private final Class<B> type = bodyType();
protected JacksonHandler() {
if (type == null) {
throw new IllegalStateException(getClass().getSimpleName()
+ " extends a body handler without naming its body type — write it as Handler<YourBody>");
}
}
/** The format this handler speaks. Injected by the subclass, resolved once at boot. */
protected abstract Codec codec();
@Override
protected final B body(Request request) throws Exception {
return codec().body(request, type);
}
}
@@ -0,0 +1,32 @@
package dev.relism.flash.ext.jackson;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.Middleware;
/**
* Turns whatever a handler returns into a serialized body.
*
* <p>Pass-through for what is already a response: {@code null}, a {@link Response}, a
* {@code byte[]} or a {@link CharSequence}. Everything else is serialized straight to bytes.
*/
public final class Marshalling {
private Marshalling() {}
public static Middleware of(ObjectMapper mapper, ContentType contentType) {
return next -> (req, res) -> {
Object out = next.handle(req, res);
if (out == null || out instanceof Response || out instanceof byte[] || out instanceof CharSequence) return out;
res.type(contentType);
try {
return mapper.writeValueAsBytes(out);
} catch (JsonProcessingException failure) {
throw new IllegalStateException("Could not serialize " + out.getClass().getName(), failure);
}
};
}
}
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.validation; package dev.relism.flash.ext.jackson;
import dev.relism.flash.exceptions.HttpException; import dev.relism.flash.exceptions.HttpException;
@@ -14,7 +14,7 @@ public final class ValidationException extends HttpException {
private final transient List<Violation> violations; private final transient List<Violation> violations;
ValidationException(List<Violation> violations) { public ValidationException(List<Violation> violations) {
super(422, describe(violations)); super(422, describe(violations));
this.violations = List.copyOf(violations); this.violations = List.copyOf(violations);
} }
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.validation; package dev.relism.flash.ext.jackson;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
@@ -31,12 +31,38 @@ public final class Validator {
private static final Check[] NONE = new Check[0]; private static final Check[] NONE = new Check[0];
/**
* One compiled validator per type, kept beside the class itself: no map lookup, no lock, and
* the entry is collected with the class rather than pinning it.
*/
private static final ClassValue<Validator> COMPILED = new ClassValue<>() {
@Override protected Validator computeValue(Class<?> type) {
return compile(type);
}
};
private final Check[] checks; private final Check[] checks;
private Validator(Check[] checks) { private Validator(Check[] checks) {
this.checks = checks; this.checks = checks;
} }
/** The constraints of {@code type}, compiled the first time it is seen and reused after. */
public static Validator of(Class<?> type) {
return COMPILED.get(type);
}
/**
* Verifies a value against its own type's constraints.
*
* @return {@code value}, so it can be used inline
* @throws ValidationException 422, listing every failure
*/
public static <T> T check(T value) {
of(value.getClass()).verify(value);
return value;
}
/** True when the type declares no constraints at all — {@link #verify} is then a no-op. */ /** True when the type declares no constraints at all — {@link #verify} is then a no-op. */
public boolean isEmpty() { public boolean isEmpty() {
return checks.length == 0; return checks.length == 0;
@@ -152,25 +178,28 @@ public final class Validator {
Class<?> type = field.getType(); Class<?> type = field.getType();
MethodHandle ref = type.isPrimitive() ? null : asReference(getter); MethodHandle ref = type.isPrimitive() ? null : asReference(getter);
if (field.isAnnotationPresent(NotNull.class) && ref != null) NotNull notNull = field.getAnnotation(NotNull.class);
checks.add(Check.reference(Check.NOT_NULL, name, "must not be null", ref)); if (notNull != null && ref != null)
checks.add(Check.reference(Check.NOT_NULL, name, said(notNull.message(), "must not be null"), ref));
if (field.isAnnotationPresent(NotBlank.class) && ref != null) NotBlank notBlank = field.getAnnotation(NotBlank.class);
checks.add(Check.reference(Check.NOT_BLANK, name, "must not be blank", ref)); if (notBlank != null && ref != null)
checks.add(Check.reference(Check.NOT_BLANK, name, said(notBlank.message(), "must not be blank"), ref));
if (field.isAnnotationPresent(NotEmpty.class) && ref != null) NotEmpty notEmpty = field.getAnnotation(NotEmpty.class);
checks.add(Check.reference(Check.NOT_EMPTY, name, "must not be empty", ref)); if (notEmpty != null && ref != null)
checks.add(Check.reference(Check.NOT_EMPTY, name, said(notEmpty.message(), "must not be empty"), ref));
Size size = field.getAnnotation(Size.class); Size size = field.getAnnotation(Size.class);
if (size != null && ref != null) if (size != null && ref != null)
checks.add(Check.size(name, sizeMessage(size), ref, size.min(), size.max())); checks.add(Check.size(name, said(size.message(), sizeMessage(size)), ref, size.min(), size.max()));
Min min = field.getAnnotation(Min.class); Min min = field.getAnnotation(Min.class);
Max max = field.getAnnotation(Max.class); Max max = field.getAnnotation(Max.class);
if (min != null || max != null) { if (min != null || max != null) {
long lo = min != null ? min.value() : Long.MIN_VALUE; long lo = min != null ? min.value() : Long.MIN_VALUE;
long hi = max != null ? max.value() : Long.MAX_VALUE; long hi = max != null ? max.value() : Long.MAX_VALUE;
String message = rangeMessage(min, max); String message = said(min != null ? min.message() : max.message(), rangeMessage(min, max));
if (isIntegralPrimitive(type)) { if (isIntegralPrimitive(type)) {
checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi)); checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi));
} else if (Number.class.isAssignableFrom(type) && ref != null) { } else if (Number.class.isAssignableFrom(type) && ref != null) {
@@ -178,14 +207,15 @@ public final class Validator {
} }
} }
if (field.isAnnotationPresent(Email.class) && ref != null) Email email = field.getAnnotation(Email.class);
checks.add(Check.reference(Check.EMAIL, name, "must be a well-formed email address", ref)); if (email != null && ref != null)
checks.add(Check.reference(Check.EMAIL, name, said(email.message(), "must be a well-formed email address"), ref));
Pattern pattern = field.getAnnotation(Pattern.class); Pattern pattern = field.getAnnotation(Pattern.class);
if (pattern != null && ref != null) { if (pattern != null && ref != null) {
// ponytail: the one allocating check Pattern.matcher() per call. The regex itself is // ponytail: the one allocating check Pattern.matcher() per call. The regex itself is
// compiled once here; swap for a structural check if a hot route ever needs it. // compiled once here; swap for a structural check if a hot route ever needs it.
checks.add(Check.pattern(name, "must match " + pattern.regexp(), ref, checks.add(Check.pattern(name, said(pattern.message(), "must match " + pattern.regexp()), ref,
java.util.regex.Pattern.compile(pattern.regexp()))); java.util.regex.Pattern.compile(pattern.regexp())));
} }
} }
@@ -202,6 +232,15 @@ public final class Validator {
return getter.asType(MethodType.methodType(long.class, Object.class)); return getter.asType(MethodType.methodType(long.class, Object.class));
} }
/**
* What a failure says: the constraint's own {@code message} when it sets one, and otherwise a
* plain description of the rule. Jakarta's defaults are bundle keys in braces, and a key is
* not something to put in front of whoever sent the request.
*/
private static String said(String message, String otherwise) {
return message == null || message.isBlank() || message.startsWith("{") ? otherwise : message;
}
private static String sizeMessage(Size size) { private static String sizeMessage(Size size) {
if (size.min() == 0) return "size must be at most " + size.max(); if (size.min() == 0) return "size must be at most " + size.max();
if (size.max() == Integer.MAX_VALUE) return "size must be at least " + size.min(); if (size.max() == Integer.MAX_VALUE) return "size must be at least " + size.min();
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.validation; package dev.relism.flash.ext.jackson;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
@@ -114,4 +114,15 @@ class ValidatorTest {
assertTrue(validator.isEmpty()); assertTrue(validator.isEmpty());
assertDoesNotThrow(() -> validator.verify(new Plain(null))); assertDoesNotThrow(() -> validator.verify(new Plain(null)));
} }
record Keyed(@jakarta.validation.constraints.Pattern(regexp = "[a-z.]+",
message = "uses lowercase letters and dots") String key) {}
@org.junit.jupiter.api.Test
void a_constraint_says_what_it_wants_in_its_own_words() {
ValidationException refused = org.junit.jupiter.api.Assertions.assertThrows(
ValidationException.class, () -> Validator.check(new Keyed("Not A Key")));
org.junit.jupiter.api.Assertions.assertEquals("key uses lowercase letters and dots", refused.getMessage());
}
} }
@@ -0,0 +1,60 @@
# flash-ext-jackson-json
JSON bodies and JSON responses.
## Install
```java
JsonExtension json = new JsonExtension();
FlashApp.create(8080)
.install(json)
.use(json.auto())
.scan("com.acme.handlers")
.startAndBlock();
```
The default mapper discovers the modules on the classpath (Java Time among them) and writes dates
as ISO strings; `new JsonExtension(mapper)` takes one of your own. `auto()` serializes whatever a
handler returns, leaving alone what is already a response: `null`, a `Response`, a `byte[]` or a
`CharSequence`.
## A handler with a body
The body type is the handler's type argument, and that is the whole declaration — it is also what
the OpenAPI document describes and what the constraints are read from:
```java
@POST("/users")
public final class CreateUser extends JsonHandler<NewUser> {
@Inject private UserService users;
@Override protected Object handle(Request req, Response res, NewUser body) {
return users.create(body);
}
}
```
A malformed body never reaches it (400), nor does one that breaks a constraint (422).
## A handler that reads it itself
```java
public final class Import extends RequestHandler {
@Inject private Json json;
@Override public Object handle(Request req, Response res) throws Exception {
return archive.store(json.body(req, Manifest.class));
}
}
```
`Json` is the [`Codec`](../flash-ext-jackson-core) for `application/json`: `body`, `write`,
`writeView`, `mapper`.
## Notes
- One mapper per application (or per scope), shared by every handler; `ObjectMapper` is
thread-safe once configured.
- Install `flash-ext-jackson-xml` beside this one when an application speaks both: a route picks
its format by the handler it extends, not by negotiation.
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-jackson-json</artifactId>
<name>flash-ext-jackson-json</name>
<description>JSON bodies and responses: the Json codec, JsonHandler and the marshalling middleware.</description>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-jackson-core</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
<!-- The constraints a body declares are described by the published document too. -->
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-openapi</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,27 @@
package dev.relism.flash.ext.jackson.json;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.ext.jackson.Codec;
import dev.relism.flash.http.ContentType;
/**
* JSON in and out, checked against the body type's own constraints.
*
* <pre>{@code
* public final class CreateItem extends RequestHandler {
* @Inject private Json json;
*
* @Override public Object handle(Request req, Response res) throws Exception {
* return items.create(json.body(req, NewItem.class));
* }
* }
* }</pre>
*
* <p>A handler whose whole body is one type has nothing to write at all: see {@link JsonHandler}.
*/
public final class Json extends Codec {
public Json(ObjectMapper mapper) {
super(mapper, ContentType.JSON);
}
}
@@ -0,0 +1,50 @@
package dev.relism.flash.ext.jackson.json;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import dev.relism.flash.ext.jackson.Marshalling;
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.routing.Middleware;
/**
* JSON for an application: the {@link Json} codec, the mapper behind it, and the middleware that
* serializes whatever a handler returns.
*
* <pre>{@code
* JsonExtension json = new JsonExtension();
* app.install(json).use(json.auto());
* }</pre>
*
* <p>The default mapper discovers the modules on the classpath (Java Time among them) and writes
* dates as ISO strings. Hand it a mapper of your own to decide otherwise.
*/
public class JsonExtension implements FlashExtension {
private final ObjectMapper mapper;
public JsonExtension() {
this(JsonMapper.builder()
.findAndAddModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build());
}
public JsonExtension(ObjectMapper mapper) {
this.mapper = mapper;
}
/** Serializes what a handler returns, unless it already returned a response, bytes or text. */
public Middleware auto() {
return Marshalling.of(mapper, ContentType.JSON);
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.provide(Json.class, new Json(mapper));
ctx.provide(ObjectMapper.class, mapper);
}
}
@@ -0,0 +1,35 @@
package dev.relism.flash.ext.jackson.json;
import dev.relism.flash.ext.jackson.Codec;
import dev.relism.flash.ext.jackson.JacksonHandler;
import dev.relism.flash.extension.Inject;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.routing.Consumes;
/**
* A handler that takes a JSON body of one type.
*
* <pre>{@code
* @POST("/users")
* public final class CreateUser extends JsonHandler<NewUser> {
* @Inject private UserService users;
*
* @Override protected Object handle(Request req, Response res, NewUser body) {
* return users.create(body);
* }
* }
* }</pre>
*
* <p>The body is read off the request stream, verified against the constraints its type declares,
* and handed over. A malformed body is a 400, a body that breaks a constraint is a 422, and
* neither ever reaches the handler. The published OpenAPI document describes the same type.
*/
@Consumes(ContentType.JSON)
public abstract class JsonHandler<B> extends JacksonHandler<B> {
@Inject private Json json;
@Override protected final Codec codec() {
return json;
}
}
@@ -1,6 +1,6 @@
package dev.relism.flash.ext.validation; package dev.relism.flash.ext.jackson.json;
import dev.relism.flash.ext.jackson.JacksonExtension; import dev.relism.flash.ext.jackson.json.JsonExtension;
import dev.relism.flash.ext.openapi.APIResponse; import dev.relism.flash.ext.openapi.APIResponse;
import dev.relism.flash.ext.openapi.ApiOperation; import dev.relism.flash.ext.openapi.ApiOperation;
import dev.relism.flash.ext.openapi.Content; import dev.relism.flash.ext.openapi.Content;
@@ -24,7 +24,7 @@ import org.junit.jupiter.api.extension.RegisterExtension;
* describes them. Nothing registers this bridge flash-ext-openapi picks the annotations up on * describes them. Nothing registers this bridge flash-ext-openapi picks the annotations up on
* its own when they are on the classpath. * its own when they are on the classpath.
*/ */
class ValidationOpenApiInteropTest { class ConstraintsInTheDocumentTest {
record Account( record Account(
@NotBlank @Size(max = 40) String name, @NotBlank @Size(max = 40) String name,
@@ -33,7 +33,7 @@ class ValidationOpenApiInteropTest {
@GET("/accounts") @GET("/accounts")
@ApiOperation(summary = "List accounts") @ApiOperation(summary = "List accounts")
@APIResponse(responseCode = "200", content = @Content(contentType = ContentType.JSON, schema = Account.class)) @APIResponse(responseCode = "200", content = @Content(schema = Account.class))
public static class ListAccounts extends RequestHandler { public static class ListAccounts extends RequestHandler {
@Override public Object handle(Request request, Response response) { @Override public Object handle(Request request, Response response) {
return new Account("alice", "a@b.com", 30); return new Account("alice", "a@b.com", 30);
@@ -42,10 +42,9 @@ class ValidationOpenApiInteropTest {
@RegisterExtension @RegisterExtension
static FlashTest app = FlashTest.of(configured -> { static FlashTest app = FlashTest.of(configured -> {
configured.install(new JacksonExtension()); configured.install(new JsonExtension());
configured.install(new ValidationExtension());
configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0")); configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0"));
configured.scan("dev.relism.flash.ext.validation"); configured.scan("dev.relism.flash.ext.jackson.json");
}); });
@Test @Test
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.jackson; package dev.relism.flash.ext.jackson.json;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
@@ -16,26 +16,25 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
class JacksonExtensionTest { class JsonExtensionTest {
@Test @Test
void configure_registers_json_mapper_and_middleware() { void configure_registers_json_mapper_and_middleware() {
FlashContext ctx = new FlashContext(); FlashContext ctx = new FlashContext();
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
JacksonExtension ext = new JacksonExtension(mapper); JsonExtension ext = new JsonExtension(mapper);
ext.configure(null, ctx); ext.configure(null, ctx);
ctx.complete(); ctx.complete();
assertNotNull(ctx.require(Json.class)); assertNotNull(ctx.require(Json.class));
assertNotNull(ctx.require(JacksonMiddleware.class));
assertSame(mapper, ctx.require(ObjectMapper.class)); assertSame(mapper, ctx.require(ObjectMapper.class));
} }
@Test @Test
void autoJson_factory_delegates_to_middleware_policy() throws Exception { void auto_marshals_what_a_handler_returns() throws Exception {
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
JacksonExtension ext = new JacksonExtension(mapper); JsonExtension ext = new JsonExtension(mapper);
RequestHandler next = new RequestHandler() { RequestHandler next = new RequestHandler() {
@Override @Override
public Object handle(Request request, Response response) { public Object handle(Request request, Response response) {
@@ -43,7 +42,7 @@ class JacksonExtensionTest {
} }
}; };
RequestHandler wrapped = new RequestHandler() { RequestHandler wrapped = new RequestHandler() {
private final SimpleHandler.FunctionalHandler delegate = ext.autoJson().wrap(next); private final SimpleHandler.FunctionalHandler delegate = ext.auto().wrap(next);
@Override @Override
public Object handle(Request request, Response response) throws Exception { public Object handle(Request request, Response response) throws Exception {
@@ -0,0 +1,78 @@
package dev.relism.flash.ext.jackson.json;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Http1HeaderMap;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestLine;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JsonHandlerTest {
public record NewUser(String name) {}
static final class Create extends JsonHandler<NewUser> {
@Override protected Object handle(Request request, Response response, NewUser body) {
return body.name();
}
}
@SuppressWarnings("rawtypes")
static final class Untyped extends JsonHandler {
@Override protected Object handle(Request request, Response response, Object body) {
return null;
}
}
@Test
void theBodyArrivesParsedAsTheTypeTheHandlerDeclares() throws Exception {
Create handler = new Create();
handler.bind(context());
Object answer = handler.handle(request("{\"name\":\"alice\"}"), new Response(200, ContentType.JSON));
assertEquals("alice", answer);
}
@Test
void aMalformedBodyIsTheUsualBadRequest() throws Exception {
Create handler = new Create();
handler.bind(context());
dev.relism.flash.exceptions.HttpException refused = assertThrows(dev.relism.flash.exceptions.HttpException.class,
() -> handler.handle(request("not json"), new Response(200, ContentType.JSON)));
assertEquals(400, refused.status());
}
@Test
void aHandlerThatNeverNamedItsBodyTypeIsRefusedAtBoot() {
IllegalStateException refused = assertThrows(IllegalStateException.class, Untyped::new);
assertTrue(refused.getMessage().contains("Handler<YourBody>"), refused.getMessage());
}
private static FlashContext context() {
FlashContext ctx = new FlashContext();
ctx.provide(Json.class, new Json(new ObjectMapper()));
ctx.complete();
return ctx;
}
private static Request request(String body) {
return new Request(new RequestLine(HttpMethod.POST,
new FastPathViews.StringByteView("/users"), null,
new FastPathViews.StringByteView("HTTP/1.1"), new Http1HeaderMap()),
body.getBytes(StandardCharsets.UTF_8));
}
}
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.jackson; package dev.relism.flash.ext.jackson.json;
import com.fasterxml.jackson.annotation.JsonView; import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -37,16 +37,11 @@ class JsonTest {
} }
@Test @Test
void bodyFrom_parses_stream_and_maps_bad_payload_to_http_400() throws Exception { void a_truncated_body_is_a_bad_request_too() throws Exception {
Json json = new Json(new ObjectMapper()); Json json = new Json(new ObjectMapper());
Request ok = request("{\"id\":\"u2\",\"name\":\"bob\"}"); HttpException ex = assertThrows(HttpException.class, () -> json.body(request("["), UserDto.class));
UserDto dto = json.bodyFrom(ok, UserDto.class);
assertEquals("u2", dto.id);
assertEquals("bob", dto.name);
Request bad = request("[");
HttpException ex = assertThrows(HttpException.class, () -> json.bodyFrom(bad, UserDto.class));
assertEquals(400, ex.status()); assertEquals(400, ex.status());
} }
@@ -1,6 +1,8 @@
package dev.relism.flash.ext.jackson; package dev.relism.flash.ext.jackson.json;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.ext.jackson.Marshalling;
import dev.relism.flash.routing.Middleware;
import dev.relism.flash.models.SimpleHandler; import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.http.ContentType; import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Request; import dev.relism.flash.models.Request;
@@ -16,13 +18,13 @@ import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
class JacksonMiddlewareTest { class MarshallingTest {
private static final Request REQ = null; private static final Request REQ = null;
@Test @Test
void autoJson_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception { void marshalling_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper()); Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice")); RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
Response res = new Response(200, ContentType.TEXT_PLAIN); Response res = new Response(200, ContentType.TEXT_PLAIN);
@@ -34,8 +36,8 @@ class JacksonMiddlewareTest {
} }
@Test @Test
void autoJson_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception { void marshalling_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper()); Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok"); Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
RequestHandler wrappedResponse = wrap(mw, payloadResponse); RequestHandler wrappedResponse = wrap(mw, payloadResponse);
@@ -55,17 +57,17 @@ class JacksonMiddlewareTest {
} }
@Test @Test
void autoJson_wraps_serialization_errors_as_illegal_state() { void marshalling_wraps_serialization_errors_as_illegal_state() {
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper()); Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
RequestHandler wrapped = wrap(mw, new CyclicDto()); RequestHandler wrapped = wrap(mw, new CyclicDto());
Response res = new Response(200, ContentType.TEXT_PLAIN); Response res = new Response(200, ContentType.TEXT_PLAIN);
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res)); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res));
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8)); assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
assertTrue(ex.getMessage().startsWith("Failed to serialize handler result as JSON:")); assertTrue(ex.getMessage().startsWith("Could not serialize"));
} }
private static RequestHandler wrap(JacksonMiddleware mw, Object fixedReturn) { private static RequestHandler wrap(Middleware mw, Object fixedReturn) {
RequestHandler next = new RequestHandler() { RequestHandler next = new RequestHandler() {
@Override @Override
public Object handle(Request request, Response response) { public Object handle(Request request, Response response) {
@@ -73,7 +75,7 @@ class JacksonMiddlewareTest {
} }
}; };
return new RequestHandler() { return new RequestHandler() {
private final SimpleHandler.FunctionalHandler delegate = mw.autoJson().wrap(next); private final SimpleHandler.FunctionalHandler delegate = mw.wrap(next);
@Override @Override
public Object handle(Request request, Response response) throws Exception { public Object handle(Request request, Response response) throws Exception {
@@ -1,6 +1,5 @@
package dev.relism.flash.ext.validation; package dev.relism.flash.ext.jackson.json;
import dev.relism.flash.ext.jackson.JacksonExtension;
import dev.relism.flash.testing.FlashTest; import dev.relism.flash.testing.FlashTest;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
@@ -12,19 +11,18 @@ import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
/** The whole path: JSON in, constraints checked, status out — with no error handling wired up. */ /** The whole path: JSON in, constraints checked, status out — with no error handling wired up. */
class ValidationRoutesTest { class ValidatedBodyTest {
record CreateUser(@NotBlank @Size(max = 8) String name, @Email String email, @Min(18) int age) {} record CreateUser(@NotBlank @Size(max = 8) String name, @Email String email, @Min(18) int age) {}
@RegisterExtension @RegisterExtension
static FlashTest app = FlashTest.of(configured -> { static FlashTest app = FlashTest.of(configured -> {
configured.install(new JacksonExtension()); configured.install(new JsonExtension());
configured.install(new ValidationExtension());
configured.ctx().onReady(() -> { configured.ctx().onReady(() -> {
Validation validation = configured.ctx().require(Validation.class); Json json = configured.ctx().require(Json.class);
configured.post("/users", (req, res) -> configured.post("/users", (req, res) ->
res.status(201).body("created:" + validation.body(req, CreateUser.class).name())); res.status(201).body("created:" + json.body(req, CreateUser.class).name()));
}); });
}); });
@@ -0,0 +1,30 @@
# flash-ext-jackson-xml
XML bodies and XML responses, over the same types as every other Jackson format.
```java
XmlExtension xml = new XmlExtension();
app.install(xml).use(xml.auto());
```
```java
@POST("/orders")
public final class PlaceOrder extends XmlHandler<Order> {
@Inject private OrderService orders;
@Override protected Object handle(Request req, Response res, Order body) {
return orders.place(body);
}
}
```
Everything [`flash-ext-jackson-json`](../flash-ext-jackson-json) does, in XML: the body is read off
the request stream, verified against the constraints its type declares, and handed over. A type
annotated for JSON works here as is — `Order` can be a JSON body on one route and an XML body on
another.
What is specific to XML is Jackson's own: `@JacksonXmlRootElement` for the root name,
`@JacksonXmlProperty(isAttribute = true)` for an attribute rather than an element, and
`@JacksonXmlElementWrapper` for how a list is wrapped. This module adds no annotations of its own.
Brings `jackson-dataformat-xml`, and with it Woodstox.
@@ -10,42 +10,34 @@
<version>2.1.0-SNAPSHOT</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-validation</artifactId> <artifactId>flash-ext-jackson-xml</artifactId>
<name>flash-ext-jackson-xml</name>
<description>XML bodies and responses, over the same types and the same constraints as every other Jackson format.</description>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash</artifactId> <artifactId>flash-ext-jackson-core</artifactId>
</dependency> </dependency>
<!--
Annotations only (~90 KB). Hibernate Validator's engine is deliberately absent: it
resolves constraints reflectively per call and pulls ~2 MB plus EL. This module
compiles the same annotations into a flat check table once per class instead.
-->
<dependency> <dependency>
<groupId>jakarta.validation</groupId> <groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jakarta.validation-api</artifactId> <artifactId>jackson-dataformat-xml</artifactId>
</dependency>
<!-- Only needed for Validation.body(...); check(...) works without it. -->
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-jackson</artifactId>
<optional>true</optional>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<scope>test</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId> <artifactId>flash-testing</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- Interop only: proves constraints reach the published schema. -->
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-openapi</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>
@@ -0,0 +1,20 @@
package dev.relism.flash.ext.jackson.xml;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import dev.relism.flash.ext.jackson.Codec;
import dev.relism.flash.http.ContentType;
/**
* XML in and out, checked against the body type's own constraints.
*
* <p>The same databind model as every other Jackson format: a type is annotated once and can be
* read as XML here and as JSON elsewhere in the same application. XML's own concerns — a root
* element name, an attribute rather than an element, how a list is wrapped — are Jackson's
* {@code @JacksonXml*} annotations on the type.
*/
public final class Xml extends Codec {
public Xml(XmlMapper mapper) {
super(mapper, ContentType.XML);
}
}
@@ -0,0 +1,44 @@
package dev.relism.flash.ext.jackson.xml;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import dev.relism.flash.ext.jackson.Marshalling;
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.routing.Middleware;
/**
* XML for an application: the {@link Xml} codec and the middleware that serializes what a handler
* returns.
*
* <pre>{@code
* XmlExtension xml = new XmlExtension();
* app.install(xml).use(xml.auto());
* }</pre>
*
* <p>Install it beside {@code JsonExtension} when an application speaks both: each provides its
* own codec, and a route picks one by the handler it extends.
*/
public class XmlExtension implements FlashExtension {
private final XmlMapper mapper;
public XmlExtension() {
this(XmlMapper.builder().findAndAddModules().build());
}
public XmlExtension(XmlMapper mapper) {
this.mapper = mapper;
}
/** Serializes what a handler returns, unless it already returned a response, bytes or text. */
public Middleware auto() {
return Marshalling.of(mapper, ContentType.XML);
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.provide(Xml.class, new Xml(mapper));
}
}
@@ -0,0 +1,24 @@
package dev.relism.flash.ext.jackson.xml;
import dev.relism.flash.ext.jackson.Codec;
import dev.relism.flash.ext.jackson.JacksonHandler;
import dev.relism.flash.extension.Inject;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.routing.Consumes;
/**
* A handler that takes an XML body of one type.
*
* <p>Everything {@code JsonHandler} does, in XML: the body is read off the request stream,
* verified against its type's constraints, and handed over. Both can live in the same
* application, on different routes, over the same types.
*/
@Consumes(ContentType.XML)
public abstract class XmlHandler<B> extends JacksonHandler<B> {
@Inject private Xml xml;
@Override protected final Codec codec() {
return xml;
}
}
@@ -0,0 +1,81 @@
package dev.relism.flash.ext.jackson.xml;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Http1HeaderMap;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestLine;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
import jakarta.validation.constraints.NotBlank;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** The same handler shape as JSON, over the same types and the same constraints. */
class XmlHandlerTest {
public record Order(@NotBlank String reference) {}
static final class Place extends XmlHandler<Order> {
@Override protected Object handle(Request request, Response response, Order body) {
return body.reference();
}
}
@Test
void theBodyArrivesParsedAsTheTypeTheHandlerDeclares() throws Exception {
Place handler = new Place();
handler.bind(context());
Object answer = handler.handle(request("<Order><reference>A-1</reference></Order>"), response());
assertEquals("A-1", answer);
}
@Test
void aBodyThatBreaksAConstraintNeverReachesTheHandler() throws Exception {
Place handler = new Place();
handler.bind(context());
HttpException refused = assertThrows(HttpException.class,
() -> handler.handle(request("<Order><reference></reference></Order>"), response()));
assertEquals(422, refused.status());
}
@Test
void aMalformedBodyIsABadRequest() throws Exception {
Place handler = new Place();
handler.bind(context());
HttpException refused = assertThrows(HttpException.class,
() -> handler.handle(request("<Order><reference>"), response()));
assertEquals(400, refused.status());
}
private static FlashContext context() {
FlashContext ctx = new FlashContext();
ctx.provide(Xml.class, new Xml(XmlMapper.builder().build()));
ctx.complete();
return ctx;
}
private static Response response() {
return new Response(200, ContentType.XML);
}
private static Request request(String body) {
return new Request(new RequestLine(HttpMethod.POST,
new FastPathViews.StringByteView("/orders"), null,
new FastPathViews.StringByteView("HTTP/1.1"), new Http1HeaderMap()),
body.getBytes(StandardCharsets.UTF_8));
}
}
@@ -1,116 +0,0 @@
# flash-ext-jackson
Jackson JSON integration for Flash with an opinionated auto-marshal middleware.
## What it provides
| Component | Description |
|---|---|
| `JacksonExtension` | Registers JSON services into `FlashContext` |
| `Json` | JSON read/write helper (`body`, `bodyFrom`, `write`, `writeView`) |
| `ObjectMapper` | Raw mapper escape hatch for advanced usage |
| `JacksonMiddleware` | `autoJson()` middleware for automatic outbound JSON marshalling |
Default mapper behavior (`new JacksonExtension()`):
- auto-discovers Jackson modules on classpath (`findAndAddModules()`)
- includes Java Time support (`jackson-datatype-jsr310`)
- writes date/time values as ISO-8601 strings (not numeric timestamps)
## Recommended default
Install the extension, then apply `autoJson()` once at app or scope level.
```java
JacksonExtension jackson = new JacksonExtension();
FlashApp app = FlashApp.create(8080)
.install(jackson)
.use(jackson.autoJson());
app.startAndBlock();
```
Behavior of `autoJson()`:
- pass-through: `null`, `Response`, `byte[]`, `String`, `CharSequence`
- any other return value: serialize to JSON `byte[]`
- sets `Content-Type: application/json` for marshalled responses
- serialization failures throw `IllegalStateException`
This keeps handlers concise while preserving Flash's direct byte write path.
## Installation
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-jackson</artifactId>
<version>1.1-indev2</version>
</dependency>
```
## Json helper API
Use `Json` when you want explicit, local control in a handler.
```java
@POST("/users")
public final class CreateUser extends RequestHandler {
private Json json;
@Override
protected void onInit() {
json = require(Json.class);
}
@Override
public Object handle(Request req, Response res) throws Exception {
CreateUserBody body = json.body(req, CreateUserBody.class);
UserDto created = service.create(body);
res.status(201);
return json.write(res, created);
}
}
```
Methods:
- `body(req, Type.class)` -> parse from `req.body().bytes()`
- `bodyFrom(req, Type.class)` -> parse from `req.body().stream()`
- `write(res, obj)` -> writes JSON string and sets JSON content type
- `writeView(res, obj, View.class)` -> JSON with Jackson `@JsonView`
- `mapper()` -> raw `ObjectMapper`
## Custom mapper
```java
ObjectMapper mapper = JsonMapper.builder()
.addModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
FlashApp.create(8080)
.install(new JacksonExtension(mapper));
```
## Scope usage
`autoJson()` works the same at scope level:
```java
JacksonExtension jackson = new JacksonExtension();
app.mount("/api", api -> {
api.use(jackson.autoJson());
api.get("/health", (req, res) -> Map.of("ok", true));
});
```
If you need to pull it from context, `JacksonMiddleware` is also provided as a service
after the app boots (same lifecycle model as other extension-provided services).
## Notes
- Install order is irrelevant (Flash two-phase extension lifecycle).
- `autoJson()` and OpenAPI are intentionally decoupled.
@@ -1,82 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-jackson</artifactId>
<properties>
<jacoco.version>0.8.12</jacoco.version>
</properties>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>jacoco-prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>jacoco-report-and-check</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1,94 +0,0 @@
package dev.relism.flash.ext.jackson;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.routing.Middleware;
/**
* Registers JSON support into the Flash extension layer.
*
* <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
* {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)}
* inside {@code onInit()} (class-based) or from a {@link FlashContext#onReady(Runnable)}
* callback (extensions).
*
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
*
* <p>{@link JacksonMiddleware} is provided under {@code JacksonMiddleware.class} and
* exposes opinionated JSON auto-marshalling middleware via {@link JacksonMiddleware#autoJson()}.
*
* <h3>Usage — composition (preferred)</h3>
* <pre>{@code
* public class MyHandler extends RequestHandler {
* private Json json;
*
* @Override protected void onInit() {
* json = require(Json.class);
* }
*
* public Object handle(Request req, Response res) throws Exception {
* MyDto dto = json.body(req, MyDto.class);
* return json.write(res, 201, dto);
* }
* }
* }</pre>
*
* <h3>Custom mapper</h3>
* <pre>{@code
* ObjectMapper mapper = JsonMapper.builder()
* .addModule(new JavaTimeModule())
* .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
* .build();
*
* FlashApp.create(8080)
* .install(new JacksonExtension(mapper));
* }</pre>
*/
public class JacksonExtension implements FlashExtension {
private final ObjectMapper mapper;
private final JacksonMiddleware middleware;
/**
* Installs with an opinionated default {@link JsonMapper}:
* auto-discovers modules on classpath (e.g. Java Time) and writes dates as ISO strings.
*/
public JacksonExtension() {
this(JsonMapper.builder()
.findAndAddModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build());
}
/** Installs with a fully configured custom {@link ObjectMapper}. */
public JacksonExtension(ObjectMapper mapper) {
this.mapper = mapper;
this.middleware = new JacksonMiddleware(mapper);
}
/**
* Opinionated outbound JSON middleware factory.
*
* <p>Use for app/scope-level registration:
* <pre>{@code
* JacksonExtension jackson = new JacksonExtension();
* app.install(jackson).use(jackson.autoJson());
* }</pre>
*/
public Middleware autoJson() {
return middleware.autoJson();
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
Json json = new Json(mapper);
ctx.provide(Json.class, json);
ctx.provide(ObjectMapper.class, mapper);
ctx.provide(JacksonMiddleware.class, middleware);
}
}
@@ -1,62 +0,0 @@
package dev.relism.flash.ext.jackson;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.Middleware;
/**
* Outbound JSON marshalling middleware for class-based and lambda routes.
*
* <p>{@link #autoJson()} marshals any non-body-native return value to JSON bytes,
* writes {@code Content-Type: application/json}, and returns {@code byte[]} so
* the Flash write path stays direct.
*
* <p>Pass-through return types:
* <ul>
* <li>{@code null}</li>
* <li>{@link Response}</li>
* <li>{@code byte[]}</li>
* <li>{@link String}</li>
* <li>{@link CharSequence}</li>
* </ul>
*/
public final class JacksonMiddleware {
private final ObjectMapper mapper;
JacksonMiddleware(ObjectMapper mapper) {
this.mapper = mapper;
}
/**
* Automatic JSON marshalling policy.
*
* <p>For non-pass-through return values, serializes with Jackson directly to
* {@code byte[]} and sets response content type to JSON.
*
* @throws IllegalStateException when serialization fails
*/
public Middleware autoJson() {
return next -> (req, res) -> {
Object out = next.handle(req, res);
if (isPassThrough(out)) return out;
res.type(ContentType.JSON);
try {
return mapper.writeValueAsBytes(out);
} catch (JsonProcessingException e) {
throw new IllegalStateException(
"Failed to serialize handler result as JSON: " + out.getClass().getName(), e);
}
};
}
private static boolean isPassThrough(Object out) {
return out == null
|| out instanceof Response
|| out instanceof byte[]
|| out instanceof CharSequence;
}
}
@@ -1,117 +0,0 @@
package dev.relism.flash.ext.jackson;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
/**
* Thread-safe JSON toolbox. Single point of access for all JSON I/O operations
* within a Flash application.
*
* <p>Retrieve once at boot time via {@code require(Json.class)} inside
* {@code onInit()}, cache in a private field, and call on the hot path
* with zero lookup or allocation overhead:
*
* <pre>{@code
* @Route(method = HttpMethod.POST, path = "/api/items")
* public class CreateItemHandler extends RequestHandler {
*
* private Json json;
*
* @Override
* protected void onInit() {
* json = require(Json.class);
* }
*
* public Object handle(Request req, Response res) throws Exception {
* CreateItemRequest body = json.body(req, CreateItemRequest.class);
* return json.write(res, itemService.create(body));
* }
* }
* }</pre>
*
* <p>The underlying {@link ObjectMapper} is shared across all handlers in the same
* scope (one instance per app / per child scope). Jackson's {@code ObjectMapper}
* is fully thread-safe after configuration — no synchronization is needed.
*
* <p>Install via {@link JacksonExtension} before calling {@code scan()} or
* {@code register()}.
*/
public final class Json {
private final ObjectMapper mapper;
/** Package-private — constructed exclusively by {@link JacksonExtension}. */
Json(ObjectMapper mapper) {
this.mapper = mapper;
}
// ── Input ─────────────────────────────────────────────────────────────────
/**
* Deserializes the full request body into an instance of {@code type}.
*
* <p>Reads {@code req.body().bytes()} in one shot. For streaming bodies
* use {@link #bodyFrom(Request, Class)} instead.
*
* @throws HttpException 400 if the body cannot be parsed as {@code type}
*/
public <T> T body(Request req, Class<T> type) throws Exception {
try {
return mapper.readValue(req.body().bytes(), type);
} catch (JsonProcessingException e) {
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
}
}
/**
* Deserializes the request body via the raw {@link java.io.InputStream},
* avoiding the intermediate {@code byte[]} allocation. Prefer this for
* large bodies or when allocation budget is tight.
*
* @throws HttpException 400 on parse failure
*/
public <T> T bodyFrom(Request req, Class<T> type) throws Exception {
try {
return mapper.readValue(req.body().stream(), type);
} catch (JsonProcessingException e) {
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
}
}
// ── Output ────────────────────────────────────────────────────────────────
/**
* Serializes {@code obj} to a JSON string and sets
* {@code Content-Type: application/json} on the response.
*
* <p>The returned string is used as the response body by the Flash runtime.
*/
public String write(Response res, Object obj) throws Exception {
res.type(ContentType.JSON);
return mapper.writeValueAsString(obj);
}
/**
* Like {@link #write} but applies a Jackson {@code @JsonView} filter,
* restricting serialization to fields visible under {@code view}.
*/
public String writeView(Response res, Object obj, Class<?> view) throws Exception {
res.type(ContentType.JSON);
return mapper.writerWithView(view).writeValueAsString(obj);
}
// ── Escape hatch ──────────────────────────────────────────────────────────
/**
* Returns the underlying {@link ObjectMapper} for advanced operations
* (custom serialization, schema generation, etc.) not covered by the
* methods above.
*/
public ObjectMapper mapper() {
return mapper;
}
}
@@ -36,7 +36,7 @@ FlashApp.create(8080)
// With custom resolvers // With custom resolvers
LimiterConfig conf = new LimiterConfig() LimiterConfig conf = new LimiterConfig()
.registerResolver("auth_user", req -> .registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous"); SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous");
FlashApp.create(8080) FlashApp.create(8080)
.install(new LimiterExtension(conf)) .install(new LimiterExtension(conf))
@@ -35,11 +35,11 @@ conf.registerResolver("ip", req -> {
LimiterConfig conf = new LimiterConfig(); LimiterConfig conf = new LimiterConfig();
``` ```
### By authenticated user (OIDC / ClaimsHolder) ### By authenticated user
```java ```java
conf.registerResolver("auth_user", req -> conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous"); SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous");
``` ```
Requests from unauthenticated users share the `"anonymous"` bucket. If you want Requests from unauthenticated users share the `"anonymous"` bucket. If you want
@@ -88,7 +88,7 @@ returns the same key for the same user regardless of endpoint; the limit is set
```java ```java
conf.registerResolver("auth_user", req -> conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anon"); SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anon");
``` ```
```java ```java
@@ -11,7 +11,7 @@ import dev.relism.flash.models.Request;
* *
* <pre>{@code * <pre>{@code
* conf.registerResolver("ip", req -> req.header("X-Forwarded-For")); * conf.registerResolver("ip", req -> req.header("X-Forwarded-For"));
* conf.registerResolver("auth_user", req -> ClaimsHolder.user().sub()); * conf.registerResolver("auth_user", req -> SecurityIdentity.current().principal().name());
* }</pre> * }</pre>
*/ */
@FunctionalInterface @FunctionalInterface
@@ -21,8 +21,8 @@ import java.util.Map;
* <pre>{@code * <pre>{@code
* LimiterConfig conf = new LimiterConfig() * LimiterConfig conf = new LimiterConfig()
* .registerResolver("auth_user", req -> { * .registerResolver("auth_user", req -> {
* // custom logic — e.g. extract sub from ClaimsHolder * // custom logic — e.g. key by the authenticated caller
* return ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous"; * return SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous";
* }); * });
* *
* app.install(new LimiterExtension(conf)); * app.install(new LimiterExtension(conf));
@@ -43,7 +43,7 @@ import java.util.Map;
* <h3>Lambda routes (via Guard)</h3> * <h3>Lambda routes (via Guard)</h3>
* <pre>{@code * <pre>{@code
* app.install(new LimiterExtension( * app.install(new LimiterExtension(
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub()))); * new LimiterConfig().registerResolver("auth_user", req -> SecurityIdentity.current().principal().name())));
* *
* // inside a FlashContext.onReady(...) callback: * // inside a FlashContext.onReady(...) callback:
* Guard guard = ctx.require(Guard.class); * Guard guard = ctx.require(Guard.class);
@@ -3,7 +3,7 @@
`flash-ext-mcp` turns a Flash5 app into an [MCP](https://modelcontextprotocol.io) (Model Context `flash-ext-mcp` turns a Flash5 app into an [MCP](https://modelcontextprotocol.io) (Model Context
Protocol) server: JSON-RPC 2.0 over the Streamable HTTP transport, tools/resources/prompts Protocol) server: JSON-RPC 2.0 over the Streamable HTTP transport, tools/resources/prompts
declared as plain classes and discovered at boot, optional OAuth2 protection built on declared as plain classes and discovered at boot, optional OAuth2 protection built on
`flash-ext-oidc`. `flash-ext-security-core`.
## Quick Start ## Quick Start
@@ -44,7 +44,7 @@ public class GetWeatherTool extends McpTool {
`tools-resources-prompts.md`. `tools-resources-prompts.md`.
- **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md` - **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
for exactly what that means and why. for exactly what that means and why.
- **Security**: optional, policy-driven OAuth2 via `flash-ext-oidc` — see `security.md`. - **Security**: authenticated by `flash-ext-security-core`, an OAuth2 protected resource when OIDC is installed — see `security.md`.
- **JSON**: this extension owns its JSON handling independently of `flash-ext-jackson` — see - **JSON**: this extension owns its JSON handling independently of `flash-ext-jackson` — see
`jackson-interop.md` for why, and how a future opt-in reuse could work. `jackson-interop.md` for why, and how a future opt-in reuse could work.
@@ -53,6 +53,4 @@ public class GetWeatherTool extends McpTool {
- [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts - [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts
- [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation - [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation
- [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707 - [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707
- [`keycloak.md`](keycloak.md) — Keycloak-specific setup cookbook: Dynamic Client Registration,
the RFC 8707 audience mapper gotcha, and how to verify/debug it
- [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson` - [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson`
@@ -24,7 +24,7 @@ see `tools-resources-prompts.md` — and the fixed `TextContent`/`TextResourceCo
as a `JsonNode` tree, not as a databound class, for the same reason — a JSON-RPC tool call's as a `JsonNode` tree, not as a databound class, for the same reason — a JSON-RPC tool call's
arguments aren't a DTO with getters/setters, they're a dynamic, per-tool-defined bag of values. arguments aren't a DTO with getters/setters, they're a dynamic, per-tool-defined bag of values.
This mirrors how `flash-ext-oidc` already handles its own internal JSON needs (`json-smart` for This mirrors how `flash-ext-security-oidc` handles its own JSON needs (Nimbus's parser for
token-endpoint responses) independently of `flash-ext-jackson` — extensions with protocol-level token-endpoint responses) independently of `flash-ext-jackson` — extensions with protocol-level
JSON needs that are shaped by a spec, not by user code, own that JSON handling themselves rather JSON needs that are shaped by a spec, not by user code, own that JSON handling themselves rather
than routing it through the app's general-purpose JSON extension. than routing it through the app's general-purpose JSON extension.
@@ -44,8 +44,7 @@ Nothing here rules out a later, additive convenience layer: `McpExtension.routes
that shared mapper as the backing for an escape hatch such as `ToolArguments.as(Class<T>)` or that shared mapper as the backing for an escape hatch such as `ToolArguments.as(Class<T>)` or
for a tool that wants to `ToolResponse.success(someRecord)` and have it serialized with the for a tool that wants to `ToolResponse.success(someRecord)` and have it serialized with the
app's own conventions — falling back to a locally-constructed default `ObjectMapper` when app's own conventions — falling back to a locally-constructed default `ObjectMapper` when
`flash-ext-jackson` isn't installed, the same "prefer shared, degrade to sane default" shape `flash-ext-jackson` isn't installed. That would be purely additive on top of the
already used for `McpSecurity.AUTO`. That would be purely additive on top of the
`JsonGenerator`-based envelope/content writing described above, not a replacement for it — the `JsonGenerator`-based envelope/content writing described above, not a replacement for it — the
fixed-shape protocol plumbing has no reason to ever go through databinding, regardless of what fixed-shape protocol plumbing has no reason to ever go through databinding, regardless of what
convenience layer gets added around it. convenience layer gets added around it.
@@ -1,107 +0,0 @@
# Keycloak cookbook
`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any
`flash-ext-oidc`-compatible provider. This is the Keycloak-specific setup: the exact Admin
Console configuration for a working MCP OAuth2 flow with open Dynamic Client Registration
(DCR) — no pre-registered clients, any MCP client self-registers on first connect.
## 1. Allow Dynamic Client Registration
MCP clients (Claude Desktop, Claude.ai, MCP Inspector, others) don't share one static OAuth
client — each has its own `redirect_uri` and none know your realm in advance. They self-register
on first connect via `POST {issuer}/clients-registrations/openid-connect` (the
`registration_endpoint` from the AS metadata document, reached via the RFC 9728 Protected
Resource Metadata document `McpExtension` publishes).
**Clients → Client registration**: remove the **Trusted Hosts** policy — it rejects anonymous
registration from hosts not on an explicit allowlist (`403` / `"Host not trusted"`), which
doesn't scale to arbitrary future agents. This does not weaken end-user authentication — DCR
only grants an app a `client_id`; every user still authenticates against Keycloak's real login
screen regardless of which client asked. Lighter hygiene policies (**Max Clients Limit**,
**Consent Required**) can stay, they don't interfere.
## 2. RFC 8707 audience: mapper on `basic`, not a custom scope
`McpOidcIntegration` rejects (403) any token whose `aud` doesn't include the MCP endpoint's
canonical URL. Keycloak doesn't add this by default. The obvious fix — a custom client scope
with an Audience mapper, marked Default, added to Allowed Client Scopes — **does not work**:
clients created via the `openid-connect` DCR endpoint only ever get scopes they explicitly
request, and most MCP clients (including MCP Inspector) don't request anything beyond what a
server tells them to via `scopes_supported` (step 3). Default-scope auto-attachment, which is
how a normal manually-created client would pick up a custom Default scope, doesn't apply to
DCR-created clients at all.
`basic` is the one built-in scope Keycloak attaches to every client unconditionally, regardless
of what it registered with. Put the audience mapper there:
1. **Client scopes → `basic`****Mappers****Add mapper****By configuration**
**Audience**.
2. **Included Custom Audience** = the exact value your server expects — check
`GET {parent-of-rootPath}/.well-known/oauth-protected-resource{rootPath}` on the running
server for the `resource` field it publishes (auto-derived from the request's
forwarded/`Host` headers — see `security.md`). Leave **Included Client Audience** empty (that
targets another Keycloak client, not a resource URL).
3. **Add to access token** = ON.
4. **Save.**
This is unconditional and works regardless of client cooperation — keep it even after step 3
below gets other claims flowing normally, since audience binding is a hard spec requirement
that shouldn't depend on a client bothering to request the right scope.
## 3. Other claims (username, email...): `scopes_supported` + Allowed Client Scopes
`OidcUser.username()`/`.email()`/`.name()` read `preferred_username`/`email`/`name` — normally
from the `profile`/`email` client scopes, which DCR clients don't get either, same root cause.
Unlike audience, this **is** fixable the "normal" way, because it doesn't need to survive a
completely uncooperative client:
`McpConfig.scopesSupported("openid", "profile", "email")` publishes those scopes in the PRM
document. MCP clients that read it (confirmed for MCP Inspector) echo them back in their DCR
registration request — `"scope": "openid profile email offline_access"` (`offline_access` is
Inspector's own addition, for refresh tokens). For that request to actually succeed, **Allowed
Client Scopes** needs, exactly:
- **`openid` listed explicitly.** The one genuinely non-obvious step: `openid` is not covered by
**Allow Default Scopes** (On by default) the way other realm-Default scopes are, even though
every OIDC request includes it. Until it's listed here, registration fails with a generic
`403 insufficient_scope` / `"Not permitted to use specified clientScope"` regardless of
whether everything else is configured correctly.
- **`offline_access` listed explicitly** — it's Optional, not Default, so `ALLOW_DEFAULT_SCOPES`
doesn't cover it either.
- **`profile`/`email` — do not list them here.** Mark them **Default** on the **Client scopes**
page (Assigned Type column) instead, and leave **Allow Default Scopes** = On. Adding an
already-Default scope to this list explicitly gets rejected on save
(`"Client scopes not allowed: [...]"`) — the list is for *additional* Optional scopes only.
With that, a real client's token comes back with `preferred_username`/`email` populated
normally.
### Fallback for anything else
For a claim not covered by `openid profile email` (a custom attribute, a role) — or for a client
that ignores `scopes_supported` entirely — add a **User Property** mapper to `basic` too
(Property `username` → Token Claim Name `preferred_username`, or whatever's needed), same as the
audience mapper in step 2. Unconditional, works regardless of client cooperation, costs one
mapper per claim, once, at the realm level — not per tool.
## Verifying without a full OAuth round-trip
**Clients → (any client) → Client scopes → Evaluate**: pick a user, run it — Default scopes
(including `basic`) apply automatically and won't appear in the "Select scope parameters"
picker, which only lists Optional ones — and check the **Generated Access Token** preview.
Confirms mappers work without a browser + real MCP client round-trip each time.
## If a real client still gets rejected
`McpOidcIntegration.audienceGuard` logs the actual mismatch at `WARN`:
```
[flash-ext-mcp] Rejecting token (RFC 8707): aud=<token's actual aud> does not include expected
resource identifier "<what this server expects>" — ...
```
`aud=null` → the `basic` mapper produced nothing (most common cause: **Included Custom
Audience** left blank — the mapper saves fine and silently does nothing without it). A non-null
`aud` that still doesn't match → compare byte-for-byte — the expected side is derived from the
request's own forwarded/`Host` headers, so scheme/host/trailing-slash mismatches show up here
directly, as does a proxy hop that drops `X-Forwarded-Host`.
+40 -152
View File
@@ -1,170 +1,58 @@
# Security # Security
Provider-specific setup steps (not generic OAuth2 mechanics) live in separate cookbooks — The MCP endpoint is secured by [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md):
[`keycloak.md`](keycloak.md) for Keycloak: enabling Dynamic Client Registration, why the RFC 8707 whatever mechanisms the application registers — OAuth2 bearer tokens, API keys, custom ones —
audience mapper needs to go on the built-in `basic` scope instead of a custom one, and the exact authenticate `/mcp` exactly as they authenticate every other route.
Allowed Client Scopes configuration `scopes_supported` needs to actually work.
## `McpSecurity` | `McpConfig.security(...)` | |
|---|---|
| `REQUIRED` (default) | every call must be authenticated; boot fails without a `SecurityExtension` |
| `NONE` | a public endpoint; a tool carrying security annotations fails the boot |
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-oidc` being ## OAuth2 protected resource
installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExtension.routes()`:
| Policy | `flash-ext-oidc` installed | `flash-ext-oidc` absent | When a registered mechanism publishes an OAuth2 issuer — `flash-ext-security-oidc` does — the endpoint
|---|---|---| behaves as the MCP authorization spec requires, with nothing to configure:
| `REQUIRED` | protected | **boot fails** (`IllegalStateException`) |
| `AUTO` (default) | protected | runs unprotected, logs a warning |
| `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected |
Use `REQUIRED` for anything you intend to run in production reachable over the network — it - `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata: the `resource` (the
turns "someone forgot to wire up OAuth2" into a startup crash instead of a silently open application's `SecurityExtension.origin(...)` plus the path), every issuer as `authorization_servers`,
endpoint. `AUTO` is meant for local development, where spinning up a real identity provider is and `scopes_supported` when `McpConfig.scopesSupported(...)` is set;
friction you don't want yet. - an anonymous call gets `401` with `WWW-Authenticate: Bearer resource_metadata="…"`;
- a token whose `aud` does not include the resource is `403` (RFC 8707) and logged at `WARN`. Credentials
that are not audience-bound, such as API keys, are unaffected.
## Why `flash-ext-oidc` is an *optional* Maven dependency, concretely For Keycloak, the audience comes from an *Audience* protocol mapper whose included custom audience is
the resource URL, attached to a client scope every MCP client receives (the built-in `basic` scope is the
one that needs no client cooperation). Clients that register dynamically need Keycloak's anonymous
client registration policies relaxed for the trusted hosts.
Maven's `<optional>true</optional>` only affects **transitive** propagation: consumers of `McpConfig.requireTokenAudience(false)` drops that last check for an authorization server that cannot
`flash-ext-mcp` don't get `flash-ext-oidc` pulled in automatically unless they add it themselves. mint a resource audience at all — Keycloak ignores RFC 8707's `resource` parameter, so a deployment that
Within `flash-ext-mcp` itself, `flash-ext-oidc`'s classes are on the compile/test classpath as cannot add the mapper has no other way in. Every token a registered issuer signs is then accepted on the
normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in endpoint, and the boot logs say so.
source.
That reference is isolated in its own class, `McpOidcIntegration`, invoked only from inside a ## Which credentials
`catch (NoClassDefFoundError)` block. A bare class-literal like `OidcMiddleware.class` (which
`ctx.find(OidcMiddleware.class)` needs) forces the JVM to resolve that type the moment it's
evaluated — if `flash-ext-oidc` is not on the *runtime* classpath at all (a genuinely
MCP-only install, no OAuth2 anywhere in the app), the first such reference throws
`NoClassDefFoundError`. Keeping that reference inside a separate, lazily-loaded class means
`McpExtension` itself loads and works fine standalone; only the attempt to actually use OIDC
fails, and only when there's something to fail. This mirrors `OidcExtension`'s own lazy bridge to
`flash-ext-openapi` — same technique, same reason.
## OAuth2 resolution details — zero-config by default By default every mechanism in the chain authenticates `/mcp`, and the session cookie too.
`McpConfig.mechanisms(...)` narrows that to the ones named: nothing else is a credential on the endpoint,
When oidc is available and `security() != NONE`, `McpOidcIntegration` (an isolated, and only their issuers are published — so a client is sent to exactly the authorization server the
lazily-loaded bridge — see its javadoc) derives everything an MCP OAuth2 resource server needs endpoint trusts.
straight from the installed `OidcMiddleware`, with no additional `McpConfig` calls required:
1. The MCP route is wrapped with `flash-ext-oidc`'s own `OidcMiddleware.protect(resourceMetadataPath)`
— the same Bearer-token/JWKS validation path used everywhere else in Flash5, plus a
`resource_metadata` challenge parameter (see below). No JWT parsing or JWKS handling is
reimplemented here.
2. An audience guard always runs after `protect(...)`: it reads the validated claims from
`ClaimsHolder` and rejects (`403`) any token whose `aud` claim does not include the resource
identifier — **RFC 8707 Resource Indicators / audience binding**, enforced unconditionally,
not opt-in. `OidcMiddleware` itself validates `aud` against its own `clientId` for ID
tokens, but deliberately does not enforce audience on access tokens (it varies by provider)
— the MCP extension adds that check on top, scoped to its own resource identifier.
3. The resource identifier is the canonical URI of the MCP endpoint, resolved **per request** by
`OidcMiddleware#selfOrigin` + `rootPath` — the same scheme/host resolution `OidcExtension`
uses for its own redirect URIs: `X-Forwarded-Host`/`X-Forwarded-Proto` when the request came
through a reverse proxy, otherwise `{selfScheme()}://{Host header}`. Behind a proxy the
`Host` alone is the upstream address the proxy dialled, which would publish a resource
identifier no client can reach. `McpConfig.resourceIdentifier(...)` still overrides it
outright for a proxy that forwards neither header.
4. The authorization server issuer is read from `OidcMiddleware#issuer()` unless
`McpConfig.authorizationServerIssuer(...)` overrides it.
## RFC 9728 Protected Resource Metadata
Whenever the endpoint ends up protected, `flash-ext-mcp` publishes a Protected Resource Metadata
document at `/.well-known/oauth-protected-resource{rootPath}` — no explicit `resourceIdentifier`/
`authorizationServerIssuer` configuration required, both are auto-derived as described above:
```json
{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/realms/myrealm"] }
```
`resource` is computed per request from the incoming request's forwarded/`Host` headers (see
above), so the document is correct without hardcoding the server's own public URL.
### `scopes_supported`
Optional per RFC 9728, omitted from the document entirely unless set via
`McpConfig.scopesSupported("openid", "profile", "email")`:
```json
{ "resource": "...", "authorization_servers": ["..."], "scopes_supported": ["openid", "profile", "email"] }
```
This is pure advertisement — token validation doesn't change based on it — but it matters in
practice: a client that ignores it and requests no scope at all (many do — see `keycloak.md`)
only gets back whatever the authorization server treats as always-included regardless of
request, which for Keycloak is just its built-in `basic` scope. A client that *does* read
`scopes_supported` and echoes it back in its authorization/token requests gets a token with the
claims those scopes actually provide (`profile``preferred_username`/`name`, etc.), without
needing every one of those claims hand-mapped onto `basic`. Set it to whatever scopes your
`McpTool`s actually read off `ClaimsHolder`/`OidcUser` — there's no way to auto-derive this list,
it depends entirely on what your tools do with the claims.
## `WWW-Authenticate: resource_metadata` (RFC 9728 §5.1)
The MCP Authorization spec **requires** a `401` to carry `resource_metadata` in
`WWW-Authenticate`, pointing at the Protected Resource Metadata document above — this is how a
spec-compliant client discovers the authorization server without out-of-band configuration.
`OidcMiddleware.protect(String resourceMetadataPath)` (an overload added specifically for this)
builds that challenge automatically:
```
WWW-Authenticate: Bearer realm="...", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
```
The plain `OidcMiddleware.protect()` (no argument), used by every other Flash5 app, is
unaffected — this parameter is additive and MCP-specific.
## Per-tool `@RolesAllowed`/`@ScopesAllowed`
`McpTool` subclasses can carry `flash-ext-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
```java ```java
@Tool(name = "delete_route", description = "Delete a route") McpConfig.builder("app").toolsPackage("com.example.tools").mechanisms(authorizationServer).build();
@RolesAllowed("admin")
public class DeleteRouteTool extends McpTool {
@Override public ToolResponse call(ToolArguments args) { ... }
}
``` ```
This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`, ## Tool policies
the thing that makes these annotations work on a `RequestHandler`) — it can't: every tool shares
one HTTP route (`POST {rootPath}`), already wrapped by whatever `McpSecurity` resolved above, so
there is no per-tool route to attach a different middleware chain to. Instead,
`McpOidcIntegration.compileToolPolicy` reads the annotations once at boot (`McpRegistry.scan`)
and compiles them into a closure (`McpAuthPolicy`) that `McpDispatcher` runs *after* the
route-wide auth has already succeeded and *before* invoking the specific tool named in the
`tools/call` request — narrowing what's already-authenticated, not replacing it. A denial is a
normal `isError: true` tool result (see `ToolResponse.error`), not an HTTP-level rejection — the
model sees why, the same as any other tool failure.
Roles are read via `OidcUser#hasRole` against `McpConfig.rolesClaimPath(...)` (default The core annotations work on tools as on handlers, checked per `tools/call` against the caller the route
`"realm_access.roles"`, matching `OidcConfig`'s own default — set this explicitly if the two authenticated:
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 ```java
tool call is already authenticated — there's no per-tool public/authenticated split the way @Tool(name = "approve", description = "Approves a pending proposal")
there is for HTTP routes, so a bare `@Authenticated` on a tool can't mean anything and would @RolesAllowed(value = "REVIEWER", on = {"project", "locale"}) // read from the tool's arguments
silently do nothing if allowed to compile. Boot fails instead, with a message pointing at public class ApproveTool extends McpTool { }
`@RolesAllowed`/`@ScopesAllowed` as the actual narrowing mechanism. ```
**Annotating a tool without active OAuth2 also fails boot**, not silently at request time: if A denial is a tool result with `isError: true` — the call reached the server, the tool did not run.
`@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 `McpConfig.middleware(...)` runs after authentication, for rate limiting, auditing or tracing.
`flash-ext-oidc`'s middleware throws `HttpException.unauthorized()`/`forbidden()` on auth
failure. Flash5's core does **not** special-case `HttpException` in the default exception
handler — the out-of-the-box `AbstractRouter` default always returns a generic `500`, regardless
of the thrown exception's embedded status code; only an app that explicitly calls
`FlashApp#onException(...)` (or installs something that does) gets `HttpException.status()`
honored.
To keep the MCP endpoint correct regardless of what the rest of the app configures,
`McpTransportGuards.httpExceptionGuard()` wraps the whole route and translates `HttpException`
into the right HTTP status itself, rather than letting it fall through to the app's (possibly
unconfigured) global handler. This is scoped entirely to the MCP route — it does not touch or
override the app's `onException` for any other route.
+17 -2
View File
@@ -19,8 +19,7 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId> <artifactId>flash-ext-security-core</artifactId>
<optional>true</optional>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.fasterxml.jackson.core</groupId> <groupId>com.fasterxml.jackson.core</groupId>
@@ -43,6 +42,22 @@
<artifactId>flash-testing</artifactId> <artifactId>flash-testing</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-test</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-oidc</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-apikey</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>
@@ -1,23 +0,0 @@
package dev.relism.flash.ext.mcp;
import java.util.function.Supplier;
/**
* Compiled per-tool authorization requirement, built once at boot by {@link McpOidcIntegration}
* from {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} subclass — {@code null}
* on {@link McpRegistry.RegisteredTool} means no restriction beyond whatever {@link McpSecurity}
* already enforces route-wide.
*
* <p>{@code check} is a closure, not a raw role/scope list — this is what lets this record (and
* its only caller, {@link McpDispatcher}) stay free of any compile-time reference to a {@code
* flash-ext-oidc} type, preserving the same classload isolation {@link McpOidcIntegration}'s
* javadoc describes for the rest of the OIDC bridge. Only the plain-JDK {@link Supplier}
* signature crosses the boundary; the closure itself, built once inside {@code
* McpOidcIntegration}, is the only place that ever touches {@code OidcUser}/{@code ClaimsHolder}.
*
* <p>Returns {@code null} from {@link #check()}{@code .get()} when authorized, or a
* human-readable denial reason otherwise — invoked once per {@code tools/call} against an
* annotated tool, never allocated on that path (the closure and its captured role/scope arrays
* are built exactly once, at boot).
*/
record McpAuthPolicy(Supplier<String> check) {}
@@ -1,5 +1,8 @@
package dev.relism.flash.ext.mcp; package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.security.AuthenticationMechanism;
import dev.relism.flash.routing.Middleware;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -24,10 +27,11 @@ public final class McpConfig {
private final String rootPath; private final String rootPath;
private final String toolsPackage; private final String toolsPackage;
private final McpSecurity security; private final McpSecurity security;
private final String resourceIdentifier; private final boolean requireTokenAudience;
private final String authorizationServerIssuer;
private final List<String> allowedOrigins; private final List<String> allowedOrigins;
private final List<String> scopesSupported; private final List<String> scopesSupported;
private final List<Middleware> middleware;
private final List<AuthenticationMechanism> mechanisms;
private McpConfig(Builder b) { private McpConfig(Builder b) {
this.name = b.name; this.name = b.name;
@@ -36,10 +40,11 @@ public final class McpConfig {
this.rootPath = b.rootPath; this.rootPath = b.rootPath;
this.toolsPackage = b.toolsPackage; this.toolsPackage = b.toolsPackage;
this.security = b.security; this.security = b.security;
this.resourceIdentifier = b.resourceIdentifier; this.requireTokenAudience = b.requireTokenAudience;
this.authorizationServerIssuer = b.authorizationServerIssuer;
this.allowedOrigins = List.copyOf(b.allowedOrigins); this.allowedOrigins = List.copyOf(b.allowedOrigins);
this.scopesSupported = List.copyOf(b.scopesSupported); this.scopesSupported = List.copyOf(b.scopesSupported);
this.middleware = List.copyOf(b.middleware);
this.mechanisms = List.copyOf(b.mechanisms);
} }
String name() { return name; } String name() { return name; }
@@ -48,10 +53,11 @@ public final class McpConfig {
String rootPath() { return rootPath; } String rootPath() { return rootPath; }
String toolsPackage() { return toolsPackage; } String toolsPackage() { return toolsPackage; }
McpSecurity security() { return security; } McpSecurity security() { return security; }
String resourceIdentifier() { return resourceIdentifier; } boolean requireTokenAudience() { return requireTokenAudience; }
String authorizationServerIssuer() { return authorizationServerIssuer; }
List<String> allowedOrigins() { return allowedOrigins; } List<String> allowedOrigins() { return allowedOrigins; }
List<String> scopesSupported() { return scopesSupported; } List<String> scopesSupported() { return scopesSupported; }
List<Middleware> middleware() { return middleware; }
List<AuthenticationMechanism> mechanisms() { return mechanisms; }
public static Builder builder(String name) { return new Builder(name); } public static Builder builder(String name) { return new Builder(name); }
@@ -61,11 +67,12 @@ public final class McpConfig {
private String instructions; private String instructions;
private String rootPath = "/mcp"; private String rootPath = "/mcp";
private String toolsPackage; private String toolsPackage;
private McpSecurity security = McpSecurity.AUTO; private McpSecurity security = McpSecurity.REQUIRED;
private String resourceIdentifier; private boolean requireTokenAudience = true;
private String authorizationServerIssuer;
private final List<String> allowedOrigins = new ArrayList<>(); private final List<String> allowedOrigins = new ArrayList<>();
private final List<String> scopesSupported = new ArrayList<>(); private final List<String> scopesSupported = new ArrayList<>();
private final List<Middleware> middleware = new ArrayList<>();
private final List<AuthenticationMechanism> mechanisms = new ArrayList<>();
private Builder(String name) { private Builder(String name) {
if (name == null || name.isBlank()) if (name == null || name.isBlank())
@@ -85,28 +92,16 @@ public final class McpConfig {
/** Package scanned for {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes. Required. */ /** Package scanned for {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes. Required. */
public Builder toolsPackage(String toolsPackage) { this.toolsPackage = toolsPackage; return this; } public Builder toolsPackage(String toolsPackage) { this.toolsPackage = toolsPackage; return this; }
/** OAuth2 requirement policy. Default {@link McpSecurity#AUTO}. */ /** Default {@link McpSecurity#REQUIRED}. */
public Builder security(McpSecurity security) { this.security = security; return this; } public Builder security(McpSecurity security) { this.security = security; return this; }
/** /**
* Canonical URI of this MCP endpoint, used for RFC 8707 audience binding: tokens whose * Whether a bearer token must name this endpoint in its {@code aud} (RFC 8707), as the MCP
* {@code aud} claim does not include this value are rejected. Optional — when * authorization spec requires. Default {@code true}. Turn it off for an authorization server
* {@code flash-ext-oidc} is installed, this is auto-derived per request from the * that cannot mint a resource audience — every token a registered issuer signs is then
* forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own * accepted on the endpoint, and a warning is logged at boot.
* redirect URIs) and audience binding is enforced unconditionally. Set this explicitly
* only to override that guess — a reverse proxy that forwards neither
* {@code X-Forwarded-Host} nor {@code X-Forwarded-Proto}.
*/ */
public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; } public Builder requireTokenAudience(boolean require) { this.requireTokenAudience = require; return this; }
/**
* Authorization server issuer URL, published in the RFC 9728 Protected Resource
* Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}}. Optional
* — when {@code flash-ext-oidc} is installed, this is auto-derived from its configured
* issuer. Set this explicitly only to override that (e.g. publishing a different issuer
* than the one actually validating tokens).
*/
public Builder authorizationServerIssuer(String issuer) { this.authorizationServerIssuer = issuer; return this; }
/** /**
* Origins allowed to call the MCP endpoint (DNS-rebinding protection, per the Streamable * Origins allowed to call the MCP endpoint (DNS-rebinding protection, per the Streamable
@@ -115,19 +110,24 @@ public final class McpConfig {
*/ */
public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; } public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; }
/** /** Published as {@code scopes_supported} in the RFC 9728 metadata, so OAuth clients request them. */
* OAuth2 scopes this server expects clients to request, published as {@code
* scopes_supported} in the RFC 9728 Protected Resource Metadata document. Optional per
* the spec — omitted from the document entirely if never set. A spec-compliant client
* reads this to know what to put in its authorization/token requests instead of
* requesting nothing; see {@code docs/keycloak.md}'s "same story for any other claim"
* section for why this matters in practice (a client that requests no scope only gets
* whatever your authorization server treats as always-included, e.g. Keycloak's `basic`).
* Purely advertisement — this server still validates whatever token it actually receives
* the same way regardless of what a client requested.
*/
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; } public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
/**
* The only mechanisms that authenticate the endpoint, and the only issuers its RFC 9728 metadata
* names: any other credential, the session cookie included, is none here. Default: the whole chain.
*/
public Builder mechanisms(AuthenticationMechanism... mechanisms) {
this.mechanisms.addAll(List.of(mechanisms));
return this;
}
/** Runs on the MCP route after the transport guards and authentication — rate limiting, auditing, tracing. */
public Builder middleware(Middleware... middleware) {
this.middleware.addAll(List.of(middleware));
return this;
}
public McpConfig build() { public McpConfig build() {
if (toolsPackage == null || toolsPackage.isBlank()) if (toolsPackage == null || toolsPackage.isBlank())
throw new IllegalStateException( throw new IllegalStateException(
@@ -3,6 +3,8 @@ package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import dev.relism.flash.http.ContentType; import dev.relism.flash.http.ContentType;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.ext.security.SecurityPolicy;
import dev.relism.flash.models.Request; import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response; import dev.relism.flash.models.Response;
@@ -18,10 +20,10 @@ import java.io.IOException;
* tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object, * tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object,
* always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only * always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only
* malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. A * malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. A
* {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link McpAuthPolicy}) is the same * {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link SecurityPolicy}) is the same
* category — {@code isError: true}, tool never invoked — not a transport-level rejection; the * category — {@code isError: true}, tool never invoked — not a transport-level rejection; the
* route-wide 401/403 for "not authenticated at all" already happened earlier, in the {@code * route-wide 401/403 already happened earlier, in the security middleware, before this
* OidcMiddleware}/audience-guard middleware chain, before this dispatcher ever runs. * dispatcher ever runs.
*/ */
final class McpDispatcher { final class McpDispatcher {
@@ -136,12 +138,14 @@ final class McpDispatcher {
if (tool == null) if (tool == null)
throw McpProtocolException.invalidParams("Unknown tool: " + name); throw McpProtocolException.invalidParams("Unknown tool: " + name);
ToolResponse result;
String denied = tool.policy() != null ? tool.policy().check().get() : null;
if (denied != null) {
result = ToolResponse.error("Tool \"" + name + "\" denied: " + denied);
} else {
ToolArguments args = new ToolArguments(params.path("arguments")); ToolArguments args = new ToolArguments(params.path("arguments"));
SecurityPolicy policy = tool.policy();
ToolResponse result;
if (policy != null && !policy.permitsScopes(SecurityIdentity.current())) {
result = ToolResponse.error("Tool \"" + name + "\" denied: missing scope");
} else if (policy != null && !policy.permitsRoles(SecurityIdentity.current(), args::getString)) {
result = ToolResponse.error("Tool \"" + name + "\" denied: missing role");
} else {
try { try {
result = tool.instance().call(args); result = tool.instance().call(args);
} catch (Exception e) { } catch (Exception e) {
@@ -1,5 +1,12 @@
package dev.relism.flash.ext.mcp; package dev.relism.flash.ext.mcp;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.security.AuthenticationEntryPoint;
import dev.relism.flash.ext.security.AuthenticationMechanism;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.ext.security.SecurityPolicy;
import dev.relism.flash.ext.security.SecurityScheme;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar; import dev.relism.flash.extension.FlashRegistrar;
@@ -9,43 +16,31 @@ import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects;
/** /**
* MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single * MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single {@code POST}
* {@code POST} JSON-RPC endpoint, stateless in this revision (no session, no SSE stream; see * JSON-RPC endpoint, stateless in this revision (see {@code docs/transport.md}) — dispatching to
* {@code docs/transport.md}) — dispatch precompiled at boot from classes annotated with * {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes under
* {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} under
* {@link McpConfig#toolsPackage(String)}. * {@link McpConfig#toolsPackage(String)}.
* *
* <pre>{@code * <pre>{@code
* // Standalone, no OAuth2 * app.install(new SecurityExtension())
* FlashApp.create(8080) * .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)))
* .install(new McpExtension(McpConfig.builder("my-mcp-server") * .install(new McpExtension(McpConfig.builder("my-server").toolsPackage("com.example.tools").build()));
* .toolsPackage("com.example.tools")
* .build()))
* .start();
*
* // With flash-ext-oidc as the OAuth2 resource server — zero extra config: issuer, canonical
* // resource identifier, RFC 8707 audience binding and RFC 9728 metadata are all derived from
* // the installed OidcExtension.
* FlashApp.create(8080)
* .install(new OidcExtension(oidcConfig))
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
* .toolsPackage("com.example.tools")
* .security(McpSecurity.REQUIRED)
* .build()))
* .start();
* }</pre> * }</pre>
* *
* <p>One server per {@code McpExtension} instance — install multiple instances (distinct * <p>Every call is authenticated by the application's security chain — OAuth2 bearer tokens, API
* {@code rootPath}, distinct {@code toolsPackage}) for multiple MCP servers on one app, * keys, anything registered. When an OAuth2 issuer is among its schemes, the endpoint is also an
* mirroring the {@code OidcExtension} multi-tenant pattern. See {@code docs/security.md} for * OAuth2 protected resource: RFC 9728 metadata, a {@code resource_metadata} challenge, and RFC 8707
* the full OAuth2 resolution rules. * audience binding for audience-bound tokens. Tool annotations are enforced per call, with
* {@code @RolesAllowed(on = ...)} reading tool arguments.
*/ */
@Slf4j @Slf4j
public class McpExtension implements FlashExtension { public class McpExtension implements FlashExtension {
private final McpConfig config; private final McpConfig config;
private volatile List<SecurityScheme> schemes;
public McpExtension(McpConfig config) { public McpExtension(McpConfig config) {
this.config = config; this.config = config;
@@ -53,68 +48,67 @@ public class McpExtension implements FlashExtension {
@Override @Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) { public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.onReady(() -> registerRoutes(app, ctx)); ctx.onReady(() -> {
} SecurityExtension security = config.security() == McpSecurity.NONE ? null : ctx.find(SecurityExtension.class)
.orElseThrow(() -> new IllegalStateException("MCP server \"" + config.name()
+ "\" requires flash-ext-security-core: install a SecurityExtension, or set McpSecurity.NONE for a public server"));
McpDispatcher dispatcher = new McpDispatcher(McpRegistry.scan(config.toolsPackage(), ctx, security),
config.name(), config.version(), config.instructions());
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) { List<Middleware> chain = new ArrayList<>(List.of(
// Resolved before scanning so McpRegistry knows, per tool, whether @RolesAllowed/ McpTransportGuards.httpExceptionGuard(), McpTransportGuards.originGuard(config.allowedOrigins())));
// @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration if (security != null) protect(app, security, chain);
// (see McpOidcIntegration#compileToolPolicy) — must run first, not after. chain.addAll(config.middleware());
McpOidcIntegration.Resolved secured = resolveSecurity(ctx); app.post(config.rootPath(), (req, res) -> {
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null, dispatcher.handle(req, res);
secured == null ? null : secured.rolesClaimPath());
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
List<Middleware> chain = new ArrayList<>(3);
chain.add(McpTransportGuards.httpExceptionGuard());
chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
if (secured != null) chain.add(secured.security());
app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; },
chain.toArray(Middleware[]::new));
registerResourceMetadata(app, secured);
}
private McpOidcIntegration.Resolved resolveSecurity(FlashContext ctx) {
if (config.security() == McpSecurity.NONE) return null;
McpOidcIntegration.Resolved resolved;
try {
resolved = McpOidcIntegration.resolve(ctx, config);
} catch (NoClassDefFoundError e) {
resolved = null; // flash-ext-oidc not on the classpath at all
}
if (resolved != null) return resolved;
if (config.security() == McpSecurity.REQUIRED) {
throw new IllegalStateException(
"McpSecurity.REQUIRED but flash-ext-oidc is not installed for MCP server \"" + config.name() +
"\" — install an OidcExtension before this McpExtension, or relax security to " +
"McpSecurity.AUTO/NONE if this server is meant to be public.");
}
log.warn("[flash-ext-mcp] MCP server \"{}\" is running WITHOUT OAuth2 protection — " +
"flash-ext-oidc is not installed and McpSecurity.AUTO degrades to unprotected. " +
"Install flash-ext-oidc or set McpSecurity.REQUIRED to make this a hard failure instead.",
config.name());
return null; return null;
} }, chain.toArray(Middleware[]::new));
/**
* RFC 9728 Protected Resource Metadata, built once security is resolved — no longer
* conditioned on {@code resourceIdentifier}/{@code authorizationServerIssuer} being set
* explicitly, since {@link McpOidcIntegration#resolve} now derives both by default. The
* {@code resource} field is computed per request (it depends on that request's own
* forwarded/{@code Host} headers) via {@link McpOidcIntegration.Resolved#resourceIdentifier()}.
*/
private void registerResourceMetadata(FlashRegistrar<?> app, McpOidcIntegration.Resolved secured) {
if (secured == null) return;
String path = "/.well-known/oauth-protected-resource" + config.rootPath();
app.get(path, (req, res) -> {
res.type(ContentType.JSON);
return McpResourceMetadata.build(
secured.resourceIdentifier().apply(req), secured.issuer(), config.scopesSupported());
}); });
} }
private void protect(FlashRegistrar<?> app, SecurityExtension security, List<Middleware> chain) {
String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
List<AuthenticationMechanism> only = config.mechanisms();
AuthenticationEntryPoint anonymous = (req, res) -> {
List<SecurityScheme> schemes = schemes(security);
res.header("WWW-Authenticate", issuers(schemes, null).isEmpty()
? String.join(", ", schemes.stream().map(SecurityScheme::challenge).toList())
: "Bearer resource_metadata=\"" + security.origin(req) + metadataPath + "\"");
throw HttpException.unauthorized();
};
chain.add(only.isEmpty() ? security.enforce(SecurityPolicy.AUTHENTICATED, anonymous)
: security.enforce(SecurityPolicy.AUTHENTICATED, anonymous, only));
if (config.requireTokenAudience()) {
chain.add(next -> (req, res) -> {
String resource = security.origin(req) + config.rootPath();
if (!SecurityIdentity.current().principal().hasAudience(resource)) {
log.warn("[flash-ext-mcp] Rejected a token not issued for {} (RFC 8707) — the authorization server must put it in aud", resource);
throw HttpException.forbidden();
}
return next.handle(req, res);
});
} else {
log.warn("[flash-ext-mcp] Token audience validation (RFC 8707) is DISABLED for {} — every token a registered issuer signs is accepted.", config.rootPath());
}
app.get(metadataPath, (req, res) -> {
List<String> issuers = issuers(schemes(security), security.origin(req));
if (issuers.isEmpty()) throw HttpException.notFound("Protected resource metadata");
res.type(ContentType.JSON);
return McpResourceMetadata.build(security.origin(req) + config.rootPath(), issuers, config.scopesSupported());
});
}
/** Resolved at the first request, once every mechanism has registered — which may be after this extension was ready. */
private List<SecurityScheme> schemes(SecurityExtension security) {
if (schemes == null) {
schemes = config.mechanisms().isEmpty() ? security.schemes()
: config.mechanisms().stream().flatMap(mechanism -> mechanism.schemes().stream()).toList();
}
return schemes;
}
/** {@code "/"} is the application's own authorization server, at {@code origin}. */
private static List<String> issuers(List<SecurityScheme> schemes, String origin) {
return schemes.stream().map(SecurityScheme::issuer).filter(Objects::nonNull).map(issuer -> issuer.equals("/") ? origin : issuer).toList();
}
} }
@@ -19,7 +19,7 @@ import java.nio.charset.StandardCharsets;
* *
* <p>Not wired to {@code flash-ext-jackson} on purpose: the MCP JSON-RPC envelope is internal * <p>Not wired to {@code flash-ext-jackson} on purpose: the MCP JSON-RPC envelope is internal
* protocol plumbing, not a user-facing serialization concern, so this extension owns its * protocol plumbing, not a user-facing serialization concern, so this extension owns its
* mapper independently — same reasoning {@code flash-ext-oidc} applies to its own JSON needs * mapper independently — the same reasoning any protocol-level extension applies to its own JSON needs
* (see {@code json-smart} there). See {@code docs/jackson-interop.md} for the full rationale * (see {@code json-smart} there). See {@code docs/jackson-interop.md} for the full rationale
* and how a future opt-in reuse of a shared {@code ObjectMapper} could work. * and how a future opt-in reuse of a shared {@code ObjectMapper} could work.
*/ */
@@ -1,180 +0,0 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.oidc.Authenticated;
import dev.relism.flash.ext.oidc.ClaimsHolder;
import dev.relism.flash.ext.oidc.OidcMiddleware;
import dev.relism.flash.ext.oidc.OidcUser;
import dev.relism.flash.ext.oidc.RolesAllowed;
import dev.relism.flash.ext.oidc.ScopesAllowed;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.Request;
import dev.relism.flash.routing.Middleware;
import lombok.extern.slf4j.Slf4j;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Lazy, isolated bridge to {@code flash-ext-oidc}.
*
* <p>References to OIDC types only ever resolve when {@link #resolve}/{@link #compileToolPolicy}
* are actually invoked — never at {@link McpExtension} class-load time — because they live in
* this separate nested class. The caller wraps the invocation in {@code catch
* (NoClassDefFoundError)}, exactly like {@code OidcExtension}'s own lazy bridge to {@code
* flash-ext-openapi}. This is what lets {@code flash-ext-mcp} run standalone (MCP-only, no
* OAuth2) when {@code flash-ext-oidc} is not even on the classpath. {@link Resolved}/{@link
* McpAuthPolicy} carry only oidc-free types back out ({@link Middleware}, {@link String}, a
* {@link Function}, a {@link Supplier}) so no other class in this package ever has to reference
* an OIDC type.
*
* <p>Zero-config by design: when {@code flash-ext-oidc} is installed, everything an MCP OAuth2
* resource server needs — issuer, canonical resource identifier, RFC 8707 audience binding, and
* a spec-compliant {@code WWW-Authenticate} challenge (RFC 9728 §5.1) — is derived straight from
* the installed {@link OidcMiddleware}, with no additional {@link McpConfig} calls.
* {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)}
* remain as explicit overrides for the rare case where that guess is wrong.
*/
@Slf4j
final class McpOidcIntegration {
private static final String[] NO_VALUES = new String[0];
private McpOidcIntegration() {}
/** Everything {@link McpExtension} needs once oidc security is resolved. */
record Resolved(Middleware security, String issuer, String rolesClaimPath,
Function<Request, String> resourceIdentifier) {}
/** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
static Resolved resolve(FlashContext ctx, McpConfig config) {
Optional<OidcMiddleware> oidc = ctx.find(OidcMiddleware.class);
if (oidc.isEmpty()) return null;
OidcMiddleware oidcMw = oidc.get();
String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
String issuer = config.authorizationServerIssuer() != null
? config.authorizationServerIssuer() : oidcMw.issuer();
Function<Request, String> resourceId = req -> config.resourceIdentifier() != null
? config.resourceIdentifier()
: OidcMiddleware.selfOrigin(req, oidcMw.selfScheme()) + config.rootPath();
Middleware protect = oidcMw.protect(resourceMetadataPath);
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
return new Resolved(secured, issuer, oidcMw.rolesClaimPath(), resourceId);
}
/**
* RFC 8707 audience binding, unconditionally enforced once oidc is protecting the MCP
* route — no longer opt-in behind an explicit {@code resourceIdentifier(...)} call.
*/
private static Middleware audienceGuard(Function<Request, String> resourceIdentifier) {
return next -> (req, res) -> {
Map<String, Object> claims = ClaimsHolder.get();
String expected = resourceIdentifier.apply(req);
if (claims != null && !audienceMatches(claims.get("aud"), expected)) {
log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " +
"resource identifier \"{}\" — the authorization server must include this exact " +
"value in the access token's aud claim (e.g. an Audience protocol mapper in " +
"Keycloak) for this MCP server to accept it.", claims.get("aud"), expected);
throw HttpException.forbidden();
}
return next.handle(req, res);
};
}
private static boolean audienceMatches(Object aud, String expected) {
if (aud instanceof String s) return s.equals(expected);
if (aud instanceof Iterable<?> it) {
for (Object o : it) if (expected.equals(String.valueOf(o))) return true;
}
return false;
}
/**
* Compiles {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool class into a {@link
* McpAuthPolicy}, or returns {@code null} if the tool carries none of the three OIDC
* annotations. Called once per tool at boot ({@link McpRegistry#scan}), never on the
* request hot path — the {@link Supplier} it returns is what runs per {@code tools/call},
* closing over the already-normalized role/scope arrays so the hot path itself allocates
* nothing beyond what {@link OidcUser#hasRole}/{@link OidcUser#hasScope} already do.
*
* <p>Fails fast at boot, not silently at request time, for the two ways this can be
* misconfigured: the annotation present without OAuth2 actually protecting this MCP server
* ({@code oidcActive == false}), and {@code @Authenticated} — which has no per-tool meaning
* here (see below) — used at all.
*/
static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> toolClass, boolean oidcActive,
String rolesClaimPath) {
Authenticated auth = toolClass.getAnnotation(Authenticated.class);
RolesAllowed roles = toolClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = toolClass.getAnnotation(ScopesAllowed.class);
if (auth == null && roles == null && scopes == null) return null;
if (!oidcActive) {
throw new IllegalStateException(
"MCP tool \"" + toolClass.getSimpleName() + "\" declares @Authenticated/@RolesAllowed/" +
"@ScopesAllowed, but this MCP server has no active OAuth2 protection — flash-ext-oidc " +
"is not installed for it, or McpSecurity is NONE. These annotations require " +
"McpSecurity.AUTO/REQUIRED with an OidcExtension installed; install one, or remove the " +
"annotation from " + toolClass.getSimpleName() + ".");
}
if (auth != null) {
throw new IllegalStateException(
"MCP tool \"" + toolClass.getSimpleName() + "\" is annotated @Authenticated, which has " +
"no effect on an McpTool: the whole MCP endpoint is already all-or-nothing " +
"authenticated once oidc is active (McpSecurity.AUTO/REQUIRED) — unlike a RequestHandler " +
"route, there is no per-tool public/authenticated split to opt into. Remove it, or use " +
"@RolesAllowed/@ScopesAllowed to narrow further.");
}
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : NO_VALUES;
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : NO_VALUES;
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
Supplier<String> check = () -> {
OidcUser user = ClaimsHolder.user();
if (user == null) return "not authenticated";
if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles))
return "missing required role (any of: " + String.join(", ", requiredRoles) + ")";
if (requiredScopes.length > 0 && !hasScopes(user, requiredScopes, scopeMatch))
return "missing required scope (" + scopeMatch + " of: " + String.join(", ", requiredScopes) + ")";
return null;
};
return new McpAuthPolicy(check);
}
private static boolean hasAnyRole(OidcUser user, String claimPath, String[] roles) {
for (String role : roles) if (user.hasRole(claimPath, role)) return true;
return false;
}
private static boolean hasScopes(OidcUser user, String[] scopes, ScopesAllowed.Match match) {
if (match == ScopesAllowed.Match.ALL) {
for (String scope : scopes) if (!user.hasScope(scope)) return false;
return true;
}
for (String scope : scopes) if (user.hasScope(scope)) return true;
return false;
}
/** Mirrors {@code OidcAuthPolicy}'s own normalization — trim, dedupe, require non-blank. */
private static String[] normalizeRequired(String annotationName, String[] values) {
if (values == null || values.length == 0)
throw new IllegalStateException("@" + annotationName + " requires at least one value");
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
for (String raw : values) {
if (raw == null) continue;
String trimmed = raw.trim();
if (!trimmed.isEmpty()) normalized.add(trimmed);
}
if (normalized.isEmpty())
throw new IllegalStateException("@" + annotationName + " requires at least one non-empty value");
return normalized.toArray(String[]::new);
}
}
@@ -2,6 +2,8 @@ package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonGenerator;
import dev.relism.flash.exceptions.InitializationException; import dev.relism.flash.exceptions.InitializationException;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityPolicy;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import java.io.IOException; import java.io.IOException;
@@ -25,7 +27,7 @@ final class McpRegistry {
private static final String EMPTY_ARRAY = "[]"; private static final String EMPTY_ARRAY = "[]";
/** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */ /** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */
record RegisteredTool(String name, McpTool instance, McpAuthPolicy policy) {} record RegisteredTool(String name, McpTool instance, SecurityPolicy policy) {}
record RegisteredResource(String uri, McpResource instance) {} record RegisteredResource(String uri, McpResource instance) {}
record RegisteredPrompt(String name, McpPrompt instance) {} record RegisteredPrompt(String name, McpPrompt instance) {}
@@ -39,15 +41,8 @@ final class McpRegistry {
private McpRegistry() {} private McpRegistry() {}
/** /** @param security {@code null} for a server running with {@link McpSecurity#NONE} */
* @param oidcActive whether this MCP server's route is actually OAuth2-protected right static McpRegistry scan(String packageName, FlashContext ctx, SecurityExtension security) {
* now (see {@link McpOidcIntegration#resolve}) — gates whether
* {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or
* rejected at boot as a misconfiguration; see
* {@link McpOidcIntegration#compileToolPolicy}.
* @param rolesClaimPath claim path resolved from the installed OIDC extension.
*/
static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) {
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName); McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
McpRegistry registry = new McpRegistry(); McpRegistry registry = new McpRegistry();
@@ -55,7 +50,9 @@ final class McpRegistry {
Tool ann = cls.getAnnotation(Tool.class); Tool ann = cls.getAnnotation(Tool.class);
McpTool instance = instantiate(cls); McpTool instance = instantiate(cls);
instance.bind(ctx); instance.bind(ctx);
McpAuthPolicy policy = compileToolPolicy(cls, oidcActive, rolesClaimPath); if (security == null && SecurityPolicy.of(cls) != null)
throw new InitializationException("MCP tool \"" + ann.name() + "\" declares security annotations, but the server runs with McpSecurity.NONE");
SecurityPolicy policy = security == null ? null : security.policy(cls);
if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null) if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null)
throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\""); throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\"");
} }
@@ -173,24 +170,6 @@ final class McpRegistry {
gen.writeEndArray(); gen.writeEndArray();
} }
/**
* Isolated the same way {@link McpOidcIntegration#resolve} is — {@code
* NoClassDefFoundError} here means {@code flash-ext-oidc} genuinely isn't on the runtime
* classpath, in which case a tool couldn't have been compiled against
* {@code @RolesAllowed}/{@code @ScopesAllowed} in the first place, so there's nothing to
* check (and nothing lost: {@code oidcActive} is only ever {@code true} once {@link
* McpOidcIntegration#resolve} has already succeeded once this boot, which proves those
* types resolve fine).
*/
private static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> cls, boolean oidcActive,
String rolesClaimPath) {
try {
return McpOidcIntegration.compileToolPolicy(cls, oidcActive, rolesClaimPath);
} catch (NoClassDefFoundError e) {
return null;
}
}
private static <T> T instantiate(Class<T> cls) { private static <T> T instantiate(Class<T> cls) {
try { try {
Constructor<T> ctor = cls.getDeclaredConstructor(); Constructor<T> ctor = cls.getDeclaredConstructor();
@@ -2,18 +2,18 @@ package dev.relism.flash.ext.mcp;
import java.util.List; import java.util.List;
/** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */ /** RFC 9728 OAuth 2.0 Protected Resource Metadata. */
final class McpResourceMetadata { final class McpResourceMetadata {
private McpResourceMetadata() {} private McpResourceMetadata() {}
/** {@code scopesSupported} is optional per RFC 9728 — omitted from the document if empty. */ /** {@code scopesSupported} is optional — omitted when empty. */
static String build(String resourceIdentifier, String authorizationServerIssuer, List<String> scopesSupported) { static String build(String resource, List<String> authorizationServers, List<String> scopesSupported) {
return McpJson.buildString(gen -> { return McpJson.buildString(gen -> {
gen.writeStartObject(); gen.writeStartObject();
gen.writeStringField("resource", resourceIdentifier); gen.writeStringField("resource", resource);
gen.writeArrayFieldStart("authorization_servers"); gen.writeArrayFieldStart("authorization_servers");
gen.writeString(authorizationServerIssuer); for (String issuer : authorizationServers) gen.writeString(issuer);
gen.writeEndArray(); gen.writeEndArray();
if (!scopesSupported.isEmpty()) { if (!scopesSupported.isEmpty()) {
gen.writeArrayFieldStart("scopes_supported"); gen.writeArrayFieldStart("scopes_supported");
@@ -1,17 +1,11 @@
package dev.relism.flash.ext.mcp; package dev.relism.flash.ext.mcp;
/** /** Whether the MCP endpoint requires an authenticated caller. */
* OAuth2 requirement policy for the MCP endpoint, resolved against whether
* {@code flash-ext-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
*/
public enum McpSecurity { public enum McpSecurity {
/** Fail fast at boot if {@code flash-ext-oidc} is not installed — never expose an unprotected MCP endpoint. */ /** The default: every call is authenticated by {@code flash-ext-security-core}, which must be installed. */
REQUIRED, REQUIRED,
/** Protect the endpoint if {@code flash-ext-oidc} is installed; otherwise run unprotected and log a warning. */ /** A public endpoint. Tools declaring security annotations fail the boot. */
AUTO,
/** Never protect the endpoint, even if {@code flash-ext-oidc} is installed elsewhere in the app. */
NONE NONE
} }
@@ -19,7 +19,7 @@ final class McpTransportGuards {
* allowed through — only a <em>present but disallowed</em> value is rejected. * allowed through — only a <em>present but disallowed</em> value is rejected.
* *
* <p>If {@code allowedOrigins} is empty, validation is skipped and a boot-time warning is * <p>If {@code allowedOrigins} is empty, validation is skipped and a boot-time warning is
* logged — same graceful-degradation shape as {@link McpSecurity#AUTO}. * logged.
*/ */
static Middleware originGuard(List<String> allowedOrigins) { static Middleware originGuard(List<String> allowedOrigins) {
if (allowedOrigins.isEmpty()) { if (allowedOrigins.isEmpty()) {
@@ -38,7 +38,7 @@ final class McpTransportGuards {
/** /**
* Safety net around the whole MCP route: translates {@link HttpException} (thrown by * Safety net around the whole MCP route: translates {@link HttpException} (thrown by
* {@link #originGuard} or by {@code flash-ext-oidc}'s middleware) into a proper HTTP status * {@link #originGuard} or by {@code flash-ext-security-core}) into a proper HTTP status
* directly, instead of relying on the app's global exception handler — which defaults to a * directly, instead of relying on the app's global exception handler — which defaults to a
* generic 500 for every exception type unless the app owner overrides it (see * generic 500 for every exception type unless the app owner overrides it (see
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint * {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint
@@ -1,109 +0,0 @@
package dev.relism.flash.ext.mcp;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Minimal, self-contained fake OIDC provider for tests: real discovery document, real JWKS
* endpoint, real RS256-signed tokens — no network dependency beyond localhost, no mocking
* framework. Exercises {@code flash-ext-oidc}'s actual discovery + JWKS + JWT validation path.
*/
final class FakeOidcProvider implements AutoCloseable {
private final HttpServer server;
private final String issuer;
private final RSAKey rsaKey;
FakeOidcProvider() throws Exception {
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
gen.initialize(2048);
KeyPair kp = gen.generateKeyPair();
this.rsaKey = new RSAKey.Builder((RSAPublicKey) kp.getPublic())
.privateKey((RSAPrivateKey) kp.getPrivate())
.keyUse(KeyUse.SIGNATURE)
.algorithm(JWSAlgorithm.RS256)
.keyID(UUID.randomUUID().toString())
.build();
this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
this.issuer = "http://127.0.0.1:" + server.getAddress().getPort();
server.createContext("/.well-known/openid-configuration", ex -> respond(ex, discoveryDocument()));
server.createContext("/jwks", ex -> respond(ex, new JWKSet(rsaKey.toPublicJWK()).toJSONObject().toString()));
server.setExecutor(null);
server.start();
}
String issuer() { return issuer; }
/** Mints a valid RS256 access token — bearer-validation only, no full authorization-code round-trip needed. */
String signToken(String subject, String audience) {
return signToken(subject, audience, null, NO_ROLES);
}
/**
* Same as {@link #signToken(String, String)}, plus a {@code scope} claim (space-delimited,
* matching {@link dev.relism.flash.ext.oidc.OidcUser#hasScope}'s default claim path) and a
* Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default
* {@code rolesClaimPath}) when {@code roles} is non-empty.
*/
String signToken(String subject, String audience, String scope, String... roles) {
try {
JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
.issuer(issuer)
.subject(subject)
.audience(audience)
.issueTime(Date.from(Instant.now()))
.expirationTime(Date.from(Instant.now().plusSeconds(300)));
if (scope != null) builder.claim("scope", scope);
if (roles.length > 0) builder.claim("realm_access", Map.of("roles", List.of(roles)));
SignedJWT jwt = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), builder.build());
jwt.sign(new RSASSASigner(rsaKey));
return jwt.serialize();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private static final String[] NO_ROLES = new String[0];
private String discoveryDocument() {
return "{"
+ "\"issuer\":\"" + issuer + "\","
+ "\"authorization_endpoint\":\"" + issuer + "/auth\","
+ "\"token_endpoint\":\"" + issuer + "/token\","
+ "\"jwks_uri\":\"" + issuer + "/jwks\""
+ "}";
}
private static void respond(com.sun.net.httpserver.HttpExchange ex, String body) throws java.io.IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().add("Content-Type", "application/json");
ex.sendResponseHeaders(200, bytes.length);
try (OutputStream os = ex.getResponseBody()) { os.write(bytes); }
}
@Override
public void close() { server.stop(0); }
}
@@ -1,141 +0,0 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.oidc.OidcConfig;
import dev.relism.flash.ext.oidc.OidcExtension;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} — see
* {@link McpOidcIntegration#compileToolPolicy}. Same real-discovery/real-JWKS/real-RS256-token
* approach as {@link McpExtensionSecurityTest}, against {@code fixtures.secured}'s tools.
*/
class McpAuthPolicyTest {
private static final String SECURED_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.secured";
private static final String AUTHENTICATED_ONLY_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.authenticatedonly";
private static final FakeOidcProvider provider = newProvider();
@RegisterExtension
static FlashTest secured = FlashTest.of(app -> {
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(SECURED_TOOLS)
.security(McpSecurity.REQUIRED)
.build()));
});
/** Tokens are audience-bound to this server, so the port has to be read back after boot. */
private static String resourceId() {
return "http://127.0.0.1:" + secured.port() + "/mcp";
}
@AfterAll
static void closeProvider() {
provider.close();
}
// ── Tool policy ──────────────────────────────────────────────────────────
@Test
void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception {
callTool("admin_only", provider.signToken("user-1", resourceId(), null))
.expectStatus(200)
.expectBodyContains("\"isError\":true")
.expectBodyContains("missing required role");
callTool("admin_only", provider.signToken("user-1", resourceId(), null, "admin"))
.expectStatus(200)
.expectBodyContains("\"isError\":false")
.expectBodyContains("ok");
}
@Test
void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception {
callTool("write_only", provider.signToken("user-1", resourceId(), "read"))
.expectStatus(200)
.expectBodyContains("\"isError\":true")
.expectBodyContains("missing required scope");
callTool("write_only", provider.signToken("user-1", resourceId(), "read write"))
.expectStatus(200)
.expectBodyContains("\"isError\":false")
.expectBodyContains("written");
}
@Test
void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception {
callTool("open", provider.signToken("user-1", resourceId(), null))
.expectStatus(200)
.expectBodyContains("\"isError\":false")
.expectBodyContains("open");
}
private static FlashResponse callTool(String toolName, String token) {
return secured.request()
.header("Accept", "application/json")
.header("Authorization", "Bearer " + token)
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\""
+ toolName + "\"}}")
.post("/mcp");
}
// ── Boot-time rejection ──────────────────────────────────────────────────
// These assert that start() throws, so they build the app directly rather than through
// FlashTest — a harness whose job is to boot an app is the wrong tool for asserting that
// booting fails. Port 0 still removes the old free-port dance.
private FlashApp bootFailure;
@AfterEach
void releaseBootFailureListener() {
if (bootFailure != null) bootFailure.stop().join();
}
@Test
void toolAnnotated_butSecurityNone_failsAtBoot() {
bootFailure = mcpApp(SECURED_TOOLS, McpSecurity.NONE);
IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start);
assertTrue(error.getMessage().contains("no active OAuth2 protection"), error.getMessage());
}
@Test
void bareAuthenticated_hasNoEffect_failsAtBoot() {
bootFailure = mcpApp(AUTHENTICATED_ONLY_TOOLS, McpSecurity.REQUIRED);
IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start);
assertTrue(error.getMessage().contains("no effect"), error.getMessage());
}
private static FlashApp mcpApp(String toolsPackage, McpSecurity security) {
FlashApp app = FlashApp.create(FlashConfiguration.builder()
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(toolsPackage)
.security(security)
.build()));
return app;
}
private static FakeOidcProvider newProvider() {
try {
return new FakeOidcProvider();
} catch (Exception failure) {
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
}
}
}
@@ -0,0 +1,61 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Application middleware on the MCP route. Until this existed a consumer had no way to put rate
* limiting, audit logging or tracing in front of {@code /mcp} — the chain was assembled entirely
* inside the extension.
*/
class McpConfigMiddlewareTest {
private static final AtomicInteger CALLS = new AtomicInteger();
@RegisterExtension
static FlashTest mcp = FlashTest.of(app -> app.install(new McpExtension(
McpConfig.builder("middleware-server")
.version("1.0.0")
.toolsPackage("dev.relism.flash.ext.mcp.fixtures")
.security(McpSecurity.NONE)
.middleware(
next -> (req, res) -> {
CALLS.incrementAndGet();
return next.handle(req, res);
},
next -> (req, res) -> {
if ("deny".equals(req.header("X-Test-Gate"))) throw HttpException.forbidden();
return next.handle(req, res);
})
.build())));
@Test
void appMiddlewareRunsOnTheMcpRoute() {
int before = CALLS.get();
mcp.request()
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
.post("/mcp")
.expectStatus(200);
assertEquals(before + 1, CALLS.get());
}
/**
* The point of the hook: with {@link McpSecurity#NONE}, an app's own guard is the only thing
* in front of the endpoint — which is how an app that does not authenticate with OAuth2
* protects {@code /mcp} at all.
*/
@Test
void appMiddlewareCanRejectTheRequest() {
mcp.request()
.header("X-Test-Gate", "deny")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
.post("/mcp")
.expectStatus(403);
}
}
@@ -1,179 +0,0 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.oidc.OidcConfig;
import dev.relism.flash.ext.oidc.OidcExtension;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashApplication;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.testing.FlashRequest;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-oidc}
* installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
* tokens — plus the fail-fast/degrade behavior when oidc is absent.
*
* <p>Four server configurations differ only in how MCP security is declared, so each gets its
* own {@link FlashTest} and they share one provider.
*/
class McpExtensionSecurityTest {
private static final String TOOLS_PACKAGE = "dev.relism.flash.ext.mcp.fixtures";
private static final String EXPLICIT_RESOURCE_ID = "https://mcp.example.com/mcp";
private static final FakeOidcProvider provider = newProvider();
/** MCP asked for AUTO security with no oidc installed — should degrade to public. */
@RegisterExtension
static FlashTest degraded = FlashTest.of(app -> app.install(new McpExtension(
McpConfig.builder("auto-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.AUTO)
.build())));
/** REQUIRED with oidc, resource identifier derived from the request. */
@RegisterExtension
static FlashTest secured = FlashTest.of(securedApp(null, null));
/** REQUIRED with oidc and an explicitly declared resource identifier. */
@RegisterExtension
static FlashTest securedWithResourceId = FlashTest.of(securedApp(EXPLICIT_RESOURCE_ID, null));
/** REQUIRED with oidc and advertised scopes. */
@RegisterExtension
static FlashTest securedWithScopes =
FlashTest.of(securedApp(null, new String[] {"openid", "profile", "email"}));
@AfterAll
static void closeProvider() {
provider.close();
}
// ── No oidc installed ────────────────────────────────────────────────────
@Test
void required_withoutOidc_throwsAtBoot() {
// Asserting that boot fails, so this one builds its app directly rather than through
// the harness; port(0) still removes the old free-port dance.
FlashApp app = FlashApp.create(FlashConfiguration.builder()
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED)
.build()));
try {
assertThrows(IllegalStateException.class, app::start);
} finally {
app.stop().join();
}
}
@Test
void auto_withoutOidc_degradesToPublic() {
post(degraded, initializeBody(), null).expectStatus(200);
}
// ── REQUIRED with oidc ───────────────────────────────────────────────────
@Test
void required_withOidc_rejectsMissingToken() {
post(secured, initializeBody(), null).expectStatus(401);
}
@Test
void required_withOidc_rejectsWrongAudience() throws Exception {
String token = provider.signToken("user-1", "https://someone-else.example.com/resource");
post(securedWithResourceId, initializeBody(), token).expectStatus(403);
}
@Test
void required_withOidc_acceptsValidAudience() throws Exception {
String token = provider.signToken("user-1", EXPLICIT_RESOURCE_ID);
post(securedWithResourceId, initializeBody(), token)
.expectStatus(200)
.expectBodyContains("\"protocolVersion\"");
}
@Test
void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception {
String derivedResourceId = "http://127.0.0.1:" + secured.port() + "/mcp";
post(secured, initializeBody(), provider.signToken("user-1", derivedResourceId))
.expectStatus(200);
post(secured, initializeBody(), provider.signToken("user-1", "https://someone-else.example.com/resource"))
.expectStatus(403);
}
@Test
void required_withOidc_missingToken_challengeIncludesResourceMetadata() {
FlashResponse response = post(secured, initializeBody(), null).expectStatus(401);
String challenge = response.header("WWW-Authenticate");
assertTrue(challenge != null && challenge.contains("resource_metadata=\"http://127.0.0.1:"
+ secured.port() + "/.well-known/oauth-protected-resource/mcp\""),
"WWW-Authenticate: " + challenge);
}
// ── Protected resource metadata ──────────────────────────────────────────
@Test
void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() {
FlashResponse response = secured.get("/.well-known/oauth-protected-resource/mcp")
.expectStatus(200)
.expectBodyContains("\"resource\":\"http://127.0.0.1:" + secured.port() + "/mcp\"")
.expectBodyContains("\"authorization_servers\":[\"" + provider.issuer() + "\"]");
assertTrue(!response.body().contains("scopes_supported"),
"scopes_supported must be omitted when unset: " + response.body());
}
@Test
void scopesSupported_published_inProtectedResourceMetadata() {
securedWithScopes.get("/.well-known/oauth-protected-resource/mcp")
.expectStatus(200)
.expectBodyContains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]");
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static FlashApplication securedApp(String resourceIdentifier, String[] scopesSupported) {
return app -> {
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
McpConfig.Builder mcp = McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED);
if (resourceIdentifier != null) mcp.resourceIdentifier(resourceIdentifier);
if (scopesSupported != null) mcp.scopesSupported(scopesSupported);
app.install(new McpExtension(mcp.build()));
};
}
private static FlashResponse post(FlashTest server, String body, String bearerToken) {
FlashRequest request = server.request().header("Accept", "application/json").json(body);
if (bearerToken != null) request.header("Authorization", "Bearer " + bearerToken);
return request.post("/mcp");
}
private static String initializeBody() {
return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}";
}
private static FakeOidcProvider newProvider() {
try {
return new FakeOidcProvider();
} catch (Exception failure) {
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
}
}
}
@@ -16,7 +16,7 @@ class McpRegistryTest {
@Test @Test
void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception { void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception {
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), false, "realm_access.roles"); McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), null);
assertTrue(registry.hasTools()); assertTrue(registry.hasTools());
assertTrue(registry.hasResources()); assertTrue(registry.hasResources());
@@ -47,7 +47,7 @@ class McpRegistryTest {
@Test @Test
void scan_emptyPackage_throwsInitializationException() { void scan_emptyPackage_throwsInitializationException() {
assertThrows(InitializationException.class, assertThrows(InitializationException.class,
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), false, "realm_access.roles")); () -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), null));
} }
private static JsonNode findByField(JsonNode array, String field, String value) { private static JsonNode findByField(JsonNode array, String field, String value) {
@@ -0,0 +1,157 @@
package dev.relism.flash.ext.mcp;
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.models.Request;
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.List;
import java.util.Map;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.assertThrows;
class McpSecurityTest {
static final FakeOidcProvider provider = start();
static final GeneratedApiKey KEY = new ApiKeyExtension<String>("mk", id -> null).generate();
static final ApiKeyExtension<String> apiKeys = new ApiKeyExtension<>("mk", id -> id.equals(KEY.id()) ? new ApiKey<>(KEY.id(), KEY.secretHash(), "agent", null, null) : null);
@RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash
.install(new SecurityExtension().roles((identity, role, on) -> identity.principal().name().equals(role + "@" + on.get("project"))))
.install(new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret")))
.install(apiKeys)
.install(new McpExtension(McpConfig.builder("secure").toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured")
.scopesSupported("openid", "email").build())));
static final OidcExtension oidc = new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret"));
/** Another issuer the application trusts, which the restricted endpoint below must neither accept nor advertise. */
static final AuthenticationMechanism elsewhere = new AuthenticationMechanism() {
@Override public Principal authenticate(Request req) {
return "Other".equals(req.header("Authorization")) ? () -> "other" : null;
}
@Override public List<SecurityScheme> schemes() {
return List.of(SecurityScheme.openIdConnect("other", "https://other.example"));
}
};
/** Only the OIDC provider authenticates this endpoint, however many mechanisms the application has. */
@RegisterExtension
static final FlashTest restricted = FlashTest.of(flash -> flash
.install(new SecurityExtension().mechanism(elsewhere))
.install(oidc)
.install(apiKeys)
.install(new McpExtension(McpConfig.builder("restricted").toolsPackage("dev.relism.flash.ext.mcp.fixtures")
.mechanisms(oidc).requireTokenAudience(false).build())));
/** The same chain with the RFC 8707 check turned off, for an authorization server that cannot mint a resource audience. */
@RegisterExtension
static final FlashTest relaxed = FlashTest.of(flash -> flash
.install(new SecurityExtension().roles((identity, role, on) -> false))
.install(new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret")))
.install(new McpExtension(McpConfig.builder("relaxed").toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured")
.requireTokenAudience(false).build())));
static FakeOidcProvider start() {
try {
return new FakeOidcProvider();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
static String resource() {
return "http://127.0.0.1:" + app.port() + "/mcp";
}
static FlashResponse call(Consumer<FlashRequest> credential, String method, String params) {
return app.request().with(credential).header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"" + method + "\",\"params\":" + params + "}")
.post("/mcp");
}
@Test
void anAnonymousCallIsChallengedWithTheResourceMetadata() {
call(request -> {}, "initialize", "{}").expectStatus(401)
.expectHeader("WWW-Authenticate", "Bearer resource_metadata=\"http://127.0.0.1:" + app.port() + "/.well-known/oauth-protected-resource/mcp\"");
}
@Test
void theProtectedResourceMetadataNamesTheIssuer() {
app.get("/.well-known/oauth-protected-resource/mcp").expectStatus(200)
.expectBody("{\"resource\":\"" + resource() + "\",\"authorization_servers\":[\"" + provider.issuer() + "\"],\"scopes_supported\":[\"openid\",\"email\"]}");
}
@Test
void aTokenIsAcceptedOnlyForThisResource() {
call(provider.bearer("u", Map.of("aud", resource())), "initialize", "{}").expectStatus(200).expectBodyContains("protocolVersion");
call(provider.bearer("u", Map.of("aud", "https://elsewhere.example/mcp")), "initialize", "{}").expectStatus(403);
}
@Test
void aTokenWithoutTheResourceAudienceIsAcceptedWhenTheCheckIsOff() {
relaxed.request().with(provider.bearer("u", Map.of("aud", "https://elsewhere.example/mcp"))).header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
.post("/mcp").expectStatus(200).expectBodyContains("protocolVersion");
}
/** The resource is the application's own origin: a forwarded header naming another host cannot make its tokens good here. */
@Test
void aForwardedHostCannotChooseTheResource() {
app.request().with(provider.bearer("u", Map.of("aud", "https://evil.example/mcp")))
.header("X-Forwarded-Proto", "https").header("X-Forwarded-Host", "evil.example").header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(403);
}
@Test
void anEndpointRestrictedToSomeMechanismsAcceptsAndAdvertisesOnlyThem() {
restricted.get("/.well-known/oauth-protected-resource/mcp").expectStatus(200)
.expectBody("{\"resource\":\"http://127.0.0.1:" + restricted.port() + "/mcp\",\"authorization_servers\":[\"" + provider.issuer() + "\"]}");
Consumer<FlashRequest> key = request -> request.header("Authorization", "Bearer " + KEY.token());
Consumer<FlashRequest> other = request -> request.header("Authorization", "Other");
for (Consumer<FlashRequest> refused : List.of(key, other)) {
restricted.request().with(refused).header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(401);
}
restricted.request().with(provider.bearer("u", Map.of())).header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(200);
}
/** An API key is not audience-bound: the same chain authenticates agents that never saw an authorization server. */
@Test
void anApiKeyIsAcceptedBesideOAuth() {
call(request -> request.header("Authorization", "Bearer " + KEY.token()), "initialize", "{}").expectStatus(200);
}
@Test
void toolPoliciesReadTheirTargetFromTheArguments() {
Consumer<FlashRequest> admin = provider.bearer("admin@42", Map.of("aud", resource()));
call(admin, "tools/call", "{\"name\":\"admin_only\",\"arguments\":{\"project\":\"42\"}}").expectBodyContains("\"isError\":false");
call(admin, "tools/call", "{\"name\":\"admin_only\",\"arguments\":{\"project\":\"7\"}}").expectBodyContains("denied: missing role");
call(provider.bearer("u", Map.of("aud", resource(), "scope", "write")), "tools/call", "{\"name\":\"write_only\"}").expectBodyContains("written");
call(provider.bearer("u", Map.of("aud", resource())), "tools/call", "{\"name\":\"write_only\"}").expectBodyContains("denied: missing scope");
}
@Test
void securityIsRequiredUnlessDeclaredOff() {
FlashTest unsecured = FlashTest.of(flash -> flash.install(new McpExtension(McpConfig.builder("x").toolsPackage("dev.relism.flash.ext.mcp.fixtures").build())));
assertThrows(Exception.class, () -> unsecured.get("/mcp"));
FlashTest contradictory = FlashTest.of(flash -> flash.install(new McpExtension(McpConfig.builder("x")
.toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured").security(McpSecurity.NONE).build())));
assertThrows(Exception.class, () -> contradictory.get("/mcp"));
}
}
@@ -1,20 +0,0 @@
package dev.relism.flash.ext.mcp.authfixtures.authenticatedonly;
import dev.relism.flash.ext.mcp.McpTool;
import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.oidc.Authenticated;
/** Deliberately misconfigured fixture: bare @Authenticated has no effect on an McpTool — see
* McpOidcIntegration#compileToolPolicy. Boot must fail with a clear message, not silently no-op. */
@Tool(name = "pointless", description = "Exists only to prove @Authenticated alone fails boot")
@Authenticated
public class PointlessAuthTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent("unreachable"));
}
}
@@ -5,10 +5,10 @@ import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool; import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments; import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse; import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.oidc.RolesAllowed; import dev.relism.flash.ext.security.RolesAllowed;
@Tool(name = "admin_only", description = "Only callable with the admin role") @Tool(name = "admin_only", description = "Only callable with the admin role")
@RolesAllowed("admin") @RolesAllowed(value = "admin", on = "project")
public class AdminOnlyTool extends McpTool { public class AdminOnlyTool extends McpTool {
@Override @Override
@@ -5,7 +5,7 @@ import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool; import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments; import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse; import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.oidc.ScopesAllowed; import dev.relism.flash.ext.security.ScopesAllowed;
@Tool(name = "write_only", description = "Only callable with the write scope") @Tool(name = "write_only", description = "Only callable with the write scope")
@ScopesAllowed("write") @ScopesAllowed("write")
-462
View File
@@ -1,462 +0,0 @@
# flash-ext-oidc
Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
Standards alignment focuses on OIDC Core + OAuth2 bearer APIs while preserving Flash's
hot-path model (middleware compiled at mount time, no heavy runtime work).
## What it provides
| Component | Description |
|---|---|
| `GET {prefix}/login` | Starts the OIDC flow: builds the authorization URL with PKCE + state, redirects |
| `GET {prefix}/callback` | Exchanges the code, validates the ID token, creates a session, redirects |
| `POST {prefix}/logout` | Invalidates the session, redirects to the provider's `end_session_endpoint` |
| `@Authenticated` | Annotation: protects a class-based handler (redirects browsers, 401 for API clients) |
| `@RolesAllowed(...)` | Annotation: protects with role check (OR semantics) |
| `@ScopesAllowed(...)` | Annotation: protects with scope check (`ALL` default, `ANY` optional) |
| `OidcMiddleware` | Programmatic middleware for lambda routes |
| `ClaimsHolder` / `OidcUser` | Thread-local user info accessible from any protected handler |
| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
## Dependencies
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
```
Transitive: `nimbus-jose-jwt`, `json-smart`.
Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically.
## Installation
```java
FlashApp.create(8080)
.install(new JacksonExtension())
.install(new OpenApiExtension(...)) // optional — enables Swagger security
.install(new OidcExtension(
OidcConfig.builder(
"https://idp.example.com",
"my-client", "my-secret", "/auth/callback")
.build()
))
.start();
```
Install order is irrelevant. The two-phase extension model guarantees all services
(including `OpenApiSecurityRegistry` from `flash-ext-openapi`) are registered before
any extension's routes phase runs.
### Keycloak shortcut
```java
OidcConfig.keycloak(
"https://keycloak.example.com", // server URL (no realm)
"myrealm", // realm
"my-client", "my-secret", // client credentials
"/auth/callback") // redirect URI (server-relative)
.https() // behind TLS
.build()
```
`keycloak()` pre-sets `rolesClaimPath("realm_access.roles")` and constructs the issuer as
`{serverUrl}/realms/{realm}`.
### Authelia / generic IdP
```java
OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/callback")
.rolesClaimPath("groups")
.build()
```
## OidcConfig reference
### Required fields
| Field | Description |
|---|---|
| `issuer` | Provider base URL — also used for OIDC discovery |
| `clientId` | OAuth2 client ID |
| `clientSecret` | OAuth2 client secret |
| `redirectUri` | Callback URI; server-relative paths (starting with `/`) are resolved at request time |
### Builder options
| Method | Default | Description |
|---|---|---|
| `.scopes("openid profile email")` | `"openid profile email"` | Space-separated requested scopes |
| `.routePrefix("/auth")` | `"/auth"` | Prefix for login/callback/logout routes |
| `.selfScheme("http")` | `"http"` | Scheme used when resolving server-relative redirect URIs |
| `.https()` | — | Shorthand for `.selfScheme("https")` |
| `.rolesClaimPath("realm_access.roles")` | `"realm_access.roles"` | Dot-path to the roles array in JWT claims |
| `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes |
| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation |
| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout |
| `.sessionStore(store)` | `InMemoryOidcSessionStore` | Custom session store (see below) |
| `.clientAuthMethod(ClientAuthMethod.POST)` | `POST` | `POST` = credentials in body; `BASIC` = `Authorization: Basic` |
| `.insecureTls()` | `false` | Disables TLS certificate verification — **development only** |
| `.schemeName("myscheme")` | derived from issuer | OpenAPI security scheme name |
### Environment variables (`OidcConfig.fromEnv()`)
```
OIDC_ISSUER required
OIDC_CLIENT_ID required
OIDC_CLIENT_SECRET required
OIDC_REDIRECT_URI required e.g. /auth/callback
OIDC_SCOPES default: openid profile email
OIDC_ROUTE_PREFIX default: /auth
OIDC_SELF_SCHEME default: http
OIDC_ROLES_CLAIM default: realm_access.roles
OIDC_SCOPE_CLAIMS default: scope,scp
OIDC_ALGORITHM default: RS256
OIDC_POST_LOGOUT_REDIRECT default: /
OIDC_CLIENT_AUTH_METHOD default: POST
```
## Protecting routes
### Class-based handlers (annotations)
```java
@Route(method = HttpMethod.GET, path = "/me")
@Authenticated
public class MePage extends JacksonHandler {
@Override
public Object handle(Request req, Response res) {
OidcUser u = ClaimsHolder.user();
return json(res, Map.of("sub", u.sub(), "email", u.email()));
}
}
@Route(method = HttpMethod.GET, path = "/admin")
@RolesAllowed("admin") // OR semantics: "admin" OR "superuser"
// @RolesAllowed({"admin", "superuser"})
public class AdminPage extends JacksonHandler { ... }
@Route(method = HttpMethod.POST, path = "/orders")
@ScopesAllowed("orders:write") // default = ALL semantics
public class CreateOrder extends JacksonHandler { ... }
@Route(method = HttpMethod.POST, path = "/payments")
@ScopesAllowed(value = {"payments:write", "payments:admin"}, match = ScopesAllowed.Match.ANY)
public class PayOrder extends JacksonHandler { ... }
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
@RolesAllowed("admin")
@ScopesAllowed("users:delete") // combined with AND semantics
public class DeleteUser extends JacksonHandler { ... }
```
The middleware is injected automatically by the annotation processor — no manual wiring needed.
Annotation composition rules:
- `@Authenticated` requires auth only
- `@RolesAllowed` implies authentication + role OR-check
- `@ScopesAllowed` implies authentication + scope check (`ALL`/`ANY`)
- combining `@RolesAllowed` + `@ScopesAllowed` uses AND semantics
- `@Authenticated(optional = true)` cannot be combined with role/scope constraints
### Lambda routes (manual middleware)
For lambda routes, pass the middleware as a varargs argument. Retrieve `OidcMiddleware`
from the context inside another extension's `routes()` phase, or after `start()`:
```java
OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
// Authentication only
app.get("/api/me", (req, res) -> {
OidcUser u = ClaimsHolder.user(); // never null here
return Map.of("sub", u.sub(), "email", u.email());
}, oidc.protect());
// Authentication + role check
app.delete("/api/admin/users/{id}", (req, res) -> {
OidcUser u = ClaimsHolder.user();
// ...
}, oidc.requireRole("admin"));
// Multiple roles (OR): passes if user holds any one of them
app.get("/api/reports", (req, res) -> { ... }, oidc.requireRole("admin", "reports-viewer"));
// Require all listed scopes
app.post("/api/orders", (req, res) -> { ... }, oidc.requireScopes("orders:write", "payments:write"));
// Require at least one listed scope
app.post("/api/payments", (req, res) -> { ... }, oidc.requireAnyScope("payments:write", "payments:admin"));
```
`oidc.protect()` / `oidc.requireRole(...)` / `oidc.requireScopes(...)` return a `Middleware` — a composable
`Handler → Handler` wrapper. Flash applies middleware right-to-left so the OIDC check
runs before your handler.
## Accessing the authenticated user
`ClaimsHolder` holds the JWT claims for the current request in a `ThreadLocal`.
It is populated by the OIDC middleware before your handler runs and cleared in the
`finally` block afterward. It is safe with virtual threads (each request gets its
own virtual thread, so `ThreadLocal` values are naturally isolated).
### OidcUser (preferred)
```java
OidcUser u = ClaimsHolder.user(); // never null inside a protected handler
String sub = u.sub(); // unique user ID
String email = u.email();
String username = u.username(); // preferred_username
String name = u.name(); // full display name
// Roles — pass the dot-path matching your provider's claim structure
List<String> roles = u.roles("realm_access.roles"); // Keycloak realm roles
List<String> clientRoles = u.roles("resource_access.my-client.roles"); // Keycloak client roles
List<String> groups = u.roles("groups"); // Authelia
boolean isAdmin = u.hasRole("realm_access.roles", "admin");
// Scopes (OIDC/OAuth2 generic): checks "scope" then "scp"
List<String> scopes = u.scopes();
boolean canWrite = u.hasScope("orders:write");
// Custom claim path resolution (for provider-specific payloads)
List<String> customScopes = u.scopes("scope,scp,permissions.scopes");
boolean canApprove = u.hasScope("permissions.scopes", "orders:approve");
// Arbitrary claim
String locale = (String) u.claim("locale");
Long exp = u.claim("exp", Long.class);
// Full raw map (escape hatch)
Map<String, Object> all = u.claims();
```
### Raw access (escape hatch)
```java
Map<String, Object> claims = ClaimsHolder.get();
String email = ClaimsHolder.claim("email");
```
## Performance
The middleware adds negligible overhead on the hot path for authenticated requests:
| Step | Cost |
|---|---|
| `Authorization` header check | `O(1)` map lookup |
| Cookie parse | `O(cookie_length)` single pass scan |
| Session lookup | `O(1)` `ConcurrentHashMap.get()` |
| Token expiry check | `O(1)` `Instant` comparison |
| `ClaimsHolder.set()` | `O(1)` `ThreadLocal.set()` |
No network calls, no cryptography, no JSON parsing on the happy path (valid session).
JWKS key fetching only happens for Bearer token validation and is cached + rate-limited by
Nimbus's `JWKSourceBuilder`. Silent token refresh only triggers when the access token expires.
Role/scope claim paths are compiled once during middleware construction (mount time), not per request.
## Authentication flow details
On each request the middleware resolves credentials in this order:
1. **Bearer token** (`Authorization: Bearer <jwt>`) — validated against JWKS.
2. **Session cookie** (`oidc_session`) — looked up in the session store; transparently
refreshed if the access token is expired (silent refresh via refresh token).
3. **No valid credentials**:
- Browser clients (no `Accept: application/json`) → redirect to `{prefix}/login?redirect={path}`
- API clients → `401 Unauthorized`
### API error semantics (RFC 6750)
For API clients (`Accept: application/json`) the middleware includes `WWW-Authenticate`:
- missing credentials: `Bearer realm="<schemeName>"`
- invalid bearer token: `Bearer realm="<schemeName>", error="invalid_token"`
- insufficient scopes: `Bearer realm="<schemeName>", error="insufficient_scope", scope="<required scopes>"`
This enables interoperable client-side handling and proper OAuth2 challenge semantics.
### Token validation (OIDC Core §3.1.3.7)
| Check | Access token | ID token |
|---|---|---|
| Signature (JWKS) | yes | yes |
| `iss` | yes | yes |
| `aud` = clientId | no (varies by provider) | yes |
| `exp`, `iat`, `sub` | yes | yes |
| `nonce` | — | yes |
JWKS keys are cached, rate-limited, and retried on cache-miss (handles key rotation).
### Claim merge strategy
At callback time the extension merges access token + ID token claims:
- Access token claims first (contains provider-specific data like `realm_access.roles`)
- ID token claims override (contains verified identity: `sub`, `email`, `name`, …)
This is provider-agnostic: authorization claims live in the AT per RFC 9068,
identity claims live in the IT per OIDC Core.
## Standards & compliance notes
This extension is designed to be compliant with the most relevant OIDC/OAuth2 RFCs:
- RFC 8414 (Authorization Server Metadata): discovery via `/.well-known/openid-configuration`
- OpenID Connect Core 1.0: Authorization Code flow + PKCE + `nonce` validation on ID token
- RFC 7636 (PKCE): S256 challenge/verifier flow
- RFC 6750 (Bearer Token Usage): `WWW-Authenticate` challenges with standard error codes
- RFC 9068 (JWT Profile for Access Tokens): JWT bearer access-token validation path
- RFC 7519 / RFC 7517 / RFC 7515 family: JWT/JWK/JWS validation via Nimbus + JWKS caching/rotation
Provider interoperability details:
- scope extraction supports both standard forms: `scope` (space-delimited string) and `scp` (list/string)
- roles remain configurable via `rolesClaimPath` (`realm_access.roles`, `groups`, etc.)
- scope claim fallback chain is configurable via `scopeClaimPaths`
## Testing scopes with Keycloak
Quick path to test `@ScopesAllowed` end-to-end:
1. **Create a client scope**
- Realm -> Client scopes -> Create
- Name: `orders:write` (or any scope name you want to enforce)
2. **Attach it to your client**
- Clients -> `<your-client>` -> Client scopes
- Add the scope as `Default` (always in token) or `Optional` (requested via `scope` param)
3. **Ensure scope mapper reaches the token**
- For most Keycloak setups this is automatic via built-in `microprofile-jwt`/scope mappers
- Verify the access token contains either `scope` string or `scp` list
4. **Request the scope in Flash config**
- Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"`
5. **Protect a handler**
- `@ScopesAllowed("orders:write")` on class-based handlers
- or `oidc.requireScopes("orders:write")` for lambda routes
6. **Verify behavior**
- token with scope -> 200
- token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope`
Useful token inspection flow while testing:
- Obtain a token from Keycloak
- Decode payload (`jwt.io` or local tool)
- check `scope` / `scp` claims
- call your protected endpoint and inspect status + `WWW-Authenticate`
## Session store
The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments.
For clustered deployments, implement `OidcSessionStore`:
```java
public interface OidcSessionStore {
void save(OidcSession session);
Optional<OidcSession> find(String sessionId);
void delete(String sessionId);
}
```
```java
OidcConfig.builder(...)
.sessionStore(new RedisOidcSessionStore(redisClient))
.build()
```
`OidcSession` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map).
## Logout
Add a logout button anywhere in your UI — a `<form>` is sufficient (no JavaScript needed):
```html
<form method="POST" action="/auth/logout">
<button type="submit">Logout</button>
</form>
```
The `POST {prefix}/logout` handler:
1. Reads the `oidc_session` cookie, looks up the session, retrieves the `id_token`.
2. Deletes the local session and clears the cookie (`Max-Age=0`).
3. If the provider has an `end_session_endpoint` (standard IdPs do), redirects there with
`?id_token_hint=<idToken>&post_logout_redirect_uri=<postLogoutRedirectUri>` — this logs
the user out of the IdP as well.
4. Otherwise redirects to `postLogoutRedirectUri` (default: `/`).
## Bearer token (API clients)
For API-to-API or SPA-to-API calls, pass a Bearer access token directly. The middleware
validates the JWT signature against JWKS and extracts the claims — no session involved:
```
Authorization: Bearer <access_token>
```
The token must be a JWT (opaque tokens are not supported). Claims are available via
`ClaimsHolder.user()` as usual.
## Multi-tenant
Multiple OIDC providers on one server — each `OidcExtension` instance is fully independent
(its own PKCE state store, session store, validator, and middleware):
```java
OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", "clientA", "secretA", "/a/auth/callback")
.routePrefix("/a/auth").schemeName("tenantA").build();
OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", "clientB", "secretB", "/b/auth/callback")
.routePrefix("/b/auth").schemeName("tenantB").build();
app.install(new OidcExtension(tenantA))
.install(new OidcExtension(tenantB));
```
To reference a specific tenant's middleware on lambda routes, keep the extension instances
and retrieve `OidcMiddleware` from context after `start()`:
```java
OidcExtension extA = new OidcExtension(tenantA);
OidcExtension extB = new OidcExtension(tenantB);
FlashApp app = FlashApp.create(8080)
.install(extA)
.install(extB)
.start()
.join(); // wait for bind
OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // last registered = tenantB
```
> **Note:** because both extensions register `OidcMiddleware.class` in the same context,
> only the last one wins under that key. For multi-tenant setups, use distinct context
> keys or provide middleware under a wrapper/alias type, or use lambda routes with explicit
> middleware captured from the extension instance before `install()`.
Class-based handlers annotated with `@Authenticated` / `@RolesAllowed` get the last
registered processor's middleware. For true multi-tenant class-based routing, install
tenant-specific annotation processors with different annotations.
## OpenAPI integration
If `flash-ext-openapi` is on the classpath and installed (order irrelevant),
the extension automatically:
- Adds a `components.securitySchemes` entry for the provider (OAuth2, authorizationCode flow)
- Adds `security` requirements to every operation whose handler carries `@Authenticated`
, `@RolesAllowed`, or `@ScopesAllowed`
No extra code needed. To customize the scheme name:
```java
OidcConfig.builder(...).schemeName("keycloak").build()
```
If `flash-ext-openapi` is absent the integration is silently skipped.
@@ -1,40 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a handler as requiring a valid JWT. Any bearer token that passes
* signature + expiry + issuer validation is accepted — no role check is performed.
*
* <p>For role-based access use {@link RolesAllowed} instead (it implies authentication).
*
* <p>Set {@code optional = true} on public routes that personalise their response when
* the user happens to be logged in but should remain accessible to guests. The middleware
* will populate {@link ClaimsHolder} if credentials are present and silently skip it
* otherwise — the request is never rejected.
*
* <pre>{@code
* // Hard auth — redirects / 401 when unauthenticated:
* @Route(method = HttpMethod.GET, path = "/api/profile")
* @Authenticated
* public class GetProfile extends JacksonHandler { ... }
*
* // Soft auth — guest-friendly, ClaimsHolder populated only when logged in:
* @Route(method = HttpMethod.GET, path = "/")
* @Authenticated(optional = true)
* public class HomePage extends HtmlHandler { ... }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Authenticated {
/**
* When {@code true} the middleware never rejects unauthenticated requests — it only
* populates {@link ClaimsHolder} when valid credentials are present.
* Defaults to {@code false} (hard authentication required).
*/
boolean optional() default false;
}
@@ -1,71 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.Map;
/**
* Thread-local store for JWT claims, populated by the OIDC middleware before
* the handler runs and cleared in the {@code finally} block afterward.
*
* <p>Safe with virtual threads: each request gets its own virtual thread, so
* {@link ThreadLocal} values are naturally isolated per request.
*
* <pre>{@code
* // Inside any handler protected by @Authenticated or @RolesAllowed:
*
* // Preferred — typed wrapper:
* OidcUser user = ClaimsHolder.user();
* String email = user.email();
* List<String> roles = user.roles("realm_access.roles");
*
* // Raw escape hatch:
* Map<String, Object> all = ClaimsHolder.get();
* }</pre>
*/
public final class ClaimsHolder {
private static final ThreadLocal<Map<String, Object>> HOLDER = new ThreadLocal<>();
private ClaimsHolder() {}
/** Called by the OIDC middleware after successful token validation. */
static void set(Map<String, Object> claims) {
HOLDER.set(claims);
}
/** Called by the OIDC middleware in the {@code finally} block. */
static void clear() {
HOLDER.remove();
}
/**
* Returns a type-safe {@link OidcUser} view of the current request's claims,
* or {@code null} if the route is not protected by OIDC middleware.
*
* <p>This is the preferred entry point for both lambda and class-based handlers.
*/
public static OidcUser user() {
Map<String, Object> claims = HOLDER.get();
return claims != null ? new OidcUser(claims) : null;
}
/**
* Returns the raw claims map for the current request, or {@code null} if
* the route is not protected by OIDC middleware.
*
* @see #user() for the preferred type-safe accessor
*/
public static Map<String, Object> get() {
return HOLDER.get();
}
/**
* Returns the value of a single claim as a String, or {@code null} if
* the claim is absent or the request is not authenticated.
*/
public static String claim(String key) {
Map<String, Object> claims = HOLDER.get();
if (claims == null) return null;
Object v = claims.get(key);
return v != null ? v.toString() : null;
}
}
@@ -1,18 +0,0 @@
package dev.relism.flash.ext.oidc;
/**
* OAuth2 client authentication method for the token endpoint (RFC 6749 §2.3).
*
* <ul>
* <li>{@link #POST} — credentials sent as {@code client_id} / {@code client_secret}
* form fields (default; most providers).</li>
* <li>{@link #BASIC} — credentials sent as an {@code Authorization: Basic} header;
* body contains only grant-specific parameters.</li>
* </ul>
*/
public enum ClientAuthMethod {
/** {@code client_secret_post} — credentials in the request body. */
POST,
/** {@code client_secret_basic} — credentials in the {@code Authorization} header. */
BASIC
}
@@ -1,50 +0,0 @@
package dev.relism.flash.ext.oidc;
import net.minidev.json.JSONValue;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
/**
* Fetches and parses the OIDC provider discovery document at
* {@code {issuer}/.well-known/openid-configuration}.
*/
final class DiscoveryClient {
private DiscoveryClient() {}
static OidcProviderMetadata fetch(String issuer, HttpClient http) throws Exception {
String url = issuer.endsWith("/")
? issuer + ".well-known/openid-configuration"
: issuer + "/.well-known/openid-configuration";
HttpResponse<String> resp = http.send(
HttpRequest.newBuilder().uri(URI.create(url)).GET().build(),
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200)
throw new IllegalStateException(
"OIDC discovery failed [" + resp.statusCode() + "]: " + url);
@SuppressWarnings("unchecked")
Map<String, Object> doc = (Map<String, Object>) JSONValue.parse(resp.body());
return new OidcProviderMetadata(
require(doc, "authorization_endpoint"),
require(doc, "token_endpoint"),
(String) doc.get("userinfo_endpoint"), // optional
require(doc, "jwks_uri"),
(String) doc.get("end_session_endpoint") // optional
);
}
private static String require(Map<String, Object> doc, String key) {
Object v = doc.get(key);
if (v == null) throw new IllegalStateException(
"Discovery doc missing required field: " + key);
return v.toString();
}
}
@@ -1,20 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe in-memory {@link OidcSessionStore}.
*
* <p>Sessions are lost on restart and not shared across instances. For
* production deployments with multiple nodes or restart-persistence requirements,
* supply a custom implementation via {@link OidcConfig.Builder#sessionStore}.
*/
public final class InMemoryOidcSessionStore implements OidcSessionStore {
private final ConcurrentHashMap<String, OidcSession> store = new ConcurrentHashMap<>();
@Override public void save(OidcSession s) { store.put(s.id(), s); }
@Override public Optional<OidcSession> find(String id) { return Optional.ofNullable(store.get(id)); }
@Override public void delete(String id) { store.remove(id); }
}
@@ -1,38 +0,0 @@
package dev.relism.flash.ext.oidc;
import net.minidev.json.JSONValue;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
/**
* Low-level JWT payload extraction — no signature or expiry validation.
*
* <p>Use only for tokens received directly from the provider over a trusted TLS
* connection (e.g. {@code id_token} from the token endpoint). Bearer tokens on
* incoming requests must go through {@link JwtValidator#validate(String)} instead.
*/
final class JwtUtils {
private JwtUtils() {}
/**
* Base64URL-decodes the JWT payload and returns the claims as a map.
* Signature, expiry, and issuer are NOT checked.
*/
@SuppressWarnings("unchecked")
static Map<String, Object> parseClaims(String jwt) {
String[] parts = jwt.split("\\.");
if (parts.length < 2) throw new IllegalArgumentException("Malformed JWT: " + jwt);
// Pad to a multiple of 4 for the standard decoder
String padded = parts[1];
switch (padded.length() % 4) {
case 2 -> padded += "==";
case 3 -> padded += "=";
}
byte[] payload = Base64.getUrlDecoder().decode(padded);
return (Map<String, Object>) JSONValue.parse(
new String(payload, StandardCharsets.UTF_8));
}
}
@@ -1,184 +0,0 @@
package dev.relism.flash.ext.oidc;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSKeySelector;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.Resource;
import com.nimbusds.jose.util.ResourceRetriever;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import dev.relism.flash.exceptions.HttpException;
import java.io.IOException;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
import java.util.Set;
/**
* Validates JWTs against a remote JWKS endpoint using Nimbus JOSE+JWT.
*
* <p>Two validation modes:
* <ul>
* <li>{@link #validate(String)} — access token bearer validation per request (hot path).
* Checks signature, {@code iss}, {@code exp}, {@code iat}, {@code sub}.
* Throws {@link HttpException} 401 so the middleware can short-circuit.</li>
* <li>{@link #validateIdToken(String, String)} — ID token validation at callback time.
* Checks signature, {@code iss}, {@code aud} == clientId, {@code exp}, {@code iat},
* {@code sub}, and {@code nonce} (if provided).
* Throws {@link OidcValidationException} (not 401 — it is a provider/protocol error).</li>
* </ul>
*
* <p>JWKS handling: the shared {@link JWKSource} uses caching + rate-limiting + automatic
* retry-on-key-miss (key rotation). Both processors share the same source — one JWKS
* fetch serves both token types.
*/
public class JwtValidator {
private final JWKSource<SecurityContext> jwkSource;
private final ConfigurableJWTProcessor<SecurityContext> accessTokenProcessor;
private final ConfigurableJWTProcessor<SecurityContext> idTokenProcessor;
private final String algorithm;
/**
* @param jwksUri JWKS endpoint URI
* @param issuer Expected {@code iss} claim
* @param clientId OAuth2 client ID — used as expected {@code aud} in ID tokens
* @param algorithm JWS algorithm (e.g. {@code "RS256"})
* @param http Shared {@link HttpClient} used for all JWKS fetches — already configured
* with the correct TLS policy (trust-all or default trust store).
*/
public JwtValidator(String jwksUri, String issuer, String clientId,
String algorithm, HttpClient http) {
try {
// Use the caller-supplied HttpClient for JWKS retrieval so that TLS policy
// (insecureTls / custom trust store) is applied consistently everywhere.
this.jwkSource = JWKSourceBuilder
.create(new URL(jwksUri), httpRetriever(http))
.cache(true)
.rateLimited(true)
.retrying(true)
.build();
} catch (Exception e) {
throw new IllegalStateException("Failed to init JWKS source: " + jwksUri, e);
}
this.algorithm = algorithm;
this.accessTokenProcessor = buildAccessTokenProcessor(jwkSource, issuer, algorithm);
this.idTokenProcessor = buildIdTokenProcessor(jwkSource, issuer, clientId, algorithm);
}
// -- Public API -----------------------------------------------------------
/**
* Validates a JWT access token (bearer on incoming request).
* Returns claims on success; throws {@link HttpException} 401 on any failure.
*/
public Map<String, Object> validate(String token) {
if (!isJwt(token)) throw HttpException.unauthorized(); // opaque token — can't validate
try {
return accessTokenProcessor.process(token, null).getClaims();
} catch (Exception e) {
throw HttpException.unauthorized();
}
}
/**
* Validates an ID token received directly from the token endpoint.
*
* <p>Checks: signature (JWKS), {@code iss}, {@code aud} == clientId,
* {@code exp}, {@code iat}, {@code sub}, and {@code nonce} if provided.
*
* @param idToken Raw ID token string
* @param nonce Nonce sent in the authorization request; {@code null} to skip check
* @throws OidcValidationException on any validation failure
*/
public Map<String, Object> validateIdToken(String idToken, String nonce) {
try {
Map<String, Object> claims = idTokenProcessor.process(idToken, null).getClaims();
if (nonce != null && !nonce.equals(claims.get("nonce")))
throw new OidcValidationException("ID token nonce mismatch", null);
return claims;
} catch (OidcValidationException e) {
throw e;
} catch (Exception e) {
throw new OidcValidationException("ID token validation failed: " + e.getMessage(), e);
}
}
/**
* Returns {@code true} if {@code token} is a signed JWT (three dot-separated Base64URL parts).
* Used to detect opaque access tokens before attempting JWKS validation.
*/
public static boolean isJwt(String token) {
if (token == null || token.isBlank()) return false;
int dots = 0;
for (int i = 0; i < token.length(); i++) if (token.charAt(i) == '.') dots++;
return dots == 2;
}
// -- Processors -----------------------------------------------------------
private static ConfigurableJWTProcessor<SecurityContext> buildAccessTokenProcessor(
JWKSource<SecurityContext> src, String issuer, String algorithm) {
ConfigurableJWTProcessor<SecurityContext> p = new DefaultJWTProcessor<>();
p.setJWSKeySelector(keySelector(src, algorithm));
// iss required; aud not enforced on ATs (varies by provider)
if (issuer != null && !issuer.isBlank()) {
p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
new JWTClaimsSet.Builder().issuer(issuer).build(),
Set.of("sub", "iat", "exp")));
}
return p;
}
private static ConfigurableJWTProcessor<SecurityContext> buildIdTokenProcessor(
JWKSource<SecurityContext> src, String issuer, String clientId, String algorithm) {
ConfigurableJWTProcessor<SecurityContext> p = new DefaultJWTProcessor<>();
p.setJWSKeySelector(keySelector(src, algorithm));
// iss + aud = clientId strictly required (OIDC Core §3.1.3.7)
JWTClaimsSet.Builder required = new JWTClaimsSet.Builder();
if (issuer != null) required.issuer(issuer);
if (clientId != null) required.audience(clientId);
p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
required.build(), Set.of("sub", "iat", "exp")));
return p;
}
private static JWSKeySelector<SecurityContext> keySelector(
JWKSource<SecurityContext> src, String algorithm) {
return new JWSVerificationKeySelector<>(JWSAlgorithm.parse(algorithm), src);
}
/**
* Wraps a {@link HttpClient} as a Nimbus {@link ResourceRetriever}.
* The client already carries the correct TLS policy (trust-all or default),
* so JWKS fetches honour the same SSL configuration as discovery and token requests.
*/
private static ResourceRetriever httpRetriever(HttpClient http) {
return url -> {
try {
HttpResponse<String> resp = http.send(
HttpRequest.newBuilder().uri(url.toURI()).GET().build(),
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200)
throw new IOException("JWKS fetch failed [" + resp.statusCode() + "]: " + url);
String contentType = resp.headers()
.firstValue("Content-Type").orElse("application/json");
return new Resource(resp.body(), contentType);
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("JWKS retrieval error: " + e.getMessage(), e);
}
};
}
}
@@ -1,98 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.LinkedHashSet;
import java.util.List;
/**
* Compiled authorization policy derived from handler annotations at mount time.
* Immutable and allocation-free on the request hot path.
*/
final class OidcAuthPolicy {
private static final String[] EMPTY = new String[0];
private static final OidcAuthPolicy AUTH_REQUIRED = new OidcAuthPolicy(
false, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
private static final OidcAuthPolicy AUTH_OPTIONAL = new OidcAuthPolicy(
true, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
private final boolean optionalAuth;
private final String[] requiredRoles;
private final String[] requiredScopes;
private final ScopesAllowed.Match scopeMatch;
private OidcAuthPolicy(boolean optionalAuth,
String[] requiredRoles,
String[] requiredScopes,
ScopesAllowed.Match scopeMatch) {
this.optionalAuth = optionalAuth;
this.requiredRoles = requiredRoles;
this.requiredScopes = requiredScopes;
this.scopeMatch = scopeMatch;
}
static OidcAuthPolicy authenticated() { return AUTH_REQUIRED; }
static OidcAuthPolicy optional() { return AUTH_OPTIONAL; }
static OidcAuthPolicy rolesAny(String... roles) {
return new OidcAuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL);
}
static OidcAuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) {
return new OidcAuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match);
}
static OidcAuthPolicy compileFromAnnotations(Class<?> handlerClass) {
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
if (auth == null && roles == null && scopes == null) return null;
boolean optionalAuth = auth != null && auth.optional();
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : EMPTY;
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : EMPTY;
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
if (optionalAuth && (requiredRoles.length > 0 || requiredScopes.length > 0)) {
throw new IllegalStateException("@Authenticated(optional = true) cannot be combined with @RolesAllowed/@ScopesAllowed on "
+ handlerClass.getName());
}
return new OidcAuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
}
static List<String> openApiScopesFor(Class<?> handlerClass) {
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
if (auth == null && roles == null && scopes == null) return null;
if (scopes == null) return List.of();
return List.of(normalizeRequired("ScopesAllowed", scopes.value()));
}
boolean optionalAuth() { return optionalAuth; }
String[] requiredRoles() { return requiredRoles; }
String[] requiredScopes() { return requiredScopes; }
ScopesAllowed.Match scopeMatch() { return scopeMatch; }
private static String[] normalizeRequired(String annotation, String[] values) {
if (values == null || values.length == 0)
throw new IllegalStateException("@" + annotation + " requires at least one value");
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
for (String raw : values) {
if (raw == null) continue;
String trimmed = raw.trim();
if (!trimmed.isEmpty()) normalized.add(trimmed);
}
if (normalized.isEmpty())
throw new IllegalStateException("@" + annotation + " requires at least one non-empty value");
return normalized.toArray(String[]::new);
}
}
@@ -1,261 +0,0 @@
package dev.relism.flash.ext.oidc;
/**
* Full OIDC client configuration. Build via
* {@link #builder(String, String, String, String)} or {@link #fromEnv()}.
*
* <p>Required fields: {@code issuer}, {@code clientId}, {@code clientSecret},
* {@code redirectUri}. Everything else has a sensible default.
*
* <p>If {@code redirectUri} starts with {@code /} it is treated as server-relative:
* the absolute URL is resolved at request time using {@link #selfScheme()} and the
* incoming {@code Host} header. Use {@link Builder#https()} when behind TLS.
*
* <pre>{@code
* // Keycloak
* OidcConfig.builder(
* "https://keycloak.example.com/realms/myrealm",
* "my-app", "secret", "/auth/callback")
* .rolesClaimPath("realm_access.roles") // Keycloak default
* .scopeClaimPaths("scope,scp") // default; supports many IdPs
* .build();
*
* // Authelia
* OidcConfig.builder(
* "https://auth.example.com",
* "my-app", "secret", "/auth/callback")
* .rolesClaimPath("groups")
* .scopeClaimPaths("scope,scp")
* .build();
*
* // Two tenants on one server
* OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", ..., "/tenantA/auth/callback")
* .routePrefix("/tenantA/auth").build();
* OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", ..., "/tenantB/auth/callback")
* .routePrefix("/tenantB/auth").build();
* app.install(new OidcExtension(tenantA))
* .install(new OidcExtension(tenantB));
* }</pre>
*/
public final class OidcConfig {
private final String issuer;
private final String clientId;
private final String clientSecret;
private final String redirectUri;
private final String scopes;
private final String routePrefix;
private final String selfScheme;
private final String rolesClaimPath;
private final String scopeClaimPaths;
private final String algorithm;
private final String postLogoutRedirectUri;
private final OidcSessionStore sessionStore;
private final boolean insecureTls;
private final ClientAuthMethod clientAuthMethod;
private final String schemeName;
private OidcConfig(Builder b) {
this.issuer = require(b.issuer, "issuer");
this.clientId = require(b.clientId, "clientId");
this.clientSecret = require(b.clientSecret, "clientSecret");
this.redirectUri = require(b.redirectUri, "redirectUri");
this.scopes = b.scopes;
this.routePrefix = b.routePrefix;
this.selfScheme = b.selfScheme;
this.rolesClaimPath = b.rolesClaimPath;
this.scopeClaimPaths = b.scopeClaimPaths;
this.algorithm = b.algorithm;
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
this.sessionStore = b.sessionStore != null ? b.sessionStore
: new InMemoryOidcSessionStore();
this.insecureTls = b.insecureTls;
this.clientAuthMethod = b.clientAuthMethod;
this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer);
}
// -- Getters --------------------------------------------------------------
public String issuer() { return issuer; }
public String clientId() { return clientId; }
public String clientSecret() { return clientSecret; }
public String redirectUri() { return redirectUri; }
public String scopes() { return scopes; }
public String routePrefix() { return routePrefix; }
public String selfScheme() { return selfScheme; }
public String rolesClaimPath() { return rolesClaimPath; }
/** Comma-separated claim paths used to read OAuth2 scopes (default: {@code "scope,scp"}). */
public String scopeClaimPaths() { return scopeClaimPaths; }
public String algorithm() { return algorithm; }
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
public OidcSessionStore sessionStore() { return sessionStore; }
/** If {@code true}, TLS certificate validation is skipped. <b>Never use in production.</b> */
public boolean insecureTls() { return insecureTls; }
public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; }
/** OpenAPI security scheme name (derived from issuer if not set explicitly). */
public String schemeName() { return schemeName; }
// -- Factory --------------------------------------------------------------
/**
* Reads configuration from environment variables:
* <pre>
* OIDC_ISSUER required
* OIDC_CLIENT_ID required
* OIDC_CLIENT_SECRET required
* OIDC_REDIRECT_URI required (e.g. /auth/callback)
* OIDC_SCOPES default: openid profile email
* OIDC_ROUTE_PREFIX default: /auth
* OIDC_SELF_SCHEME default: http
* OIDC_ROLES_CLAIM default: realm_access.roles
* OIDC_SCOPE_CLAIMS default: scope,scp
* OIDC_ALGORITHM default: RS256
* OIDC_POST_LOGOUT_REDIRECT default: /
* </pre>
*/
public static OidcConfig fromEnv() {
return builder(env("OIDC_ISSUER"), env("OIDC_CLIENT_ID"),
env("OIDC_CLIENT_SECRET"), env("OIDC_REDIRECT_URI"))
.scopes (envOr("OIDC_SCOPES", "openid profile email"))
.routePrefix (envOr("OIDC_ROUTE_PREFIX", "/auth"))
.selfScheme (envOr("OIDC_SELF_SCHEME", "http"))
.rolesClaimPath (envOr("OIDC_ROLES_CLAIM", "realm_access.roles"))
.scopeClaimPaths (envOr("OIDC_SCOPE_CLAIMS", "scope,scp"))
.algorithm (envOr("OIDC_ALGORITHM", "RS256"))
.postLogoutRedirectUri(envOr("OIDC_POST_LOGOUT_REDIRECT", "/"))
.clientAuthMethod(ClientAuthMethod.valueOf(
envOr("OIDC_CLIENT_AUTH_METHOD", "POST").toUpperCase()))
.build();
}
public static Builder builder(String issuer, String clientId,
String clientSecret, String redirectUri) {
return new Builder(issuer, clientId, clientSecret, redirectUri);
}
/**
* Convenience factory for Keycloak: constructs the issuer as
* {@code {serverUrl}/realms/{realm}} automatically.
*
* <pre>{@code
* OidcConfig.keycloak(
* "https://keycloak.example.com", "flashboard",
* "my-app", "secret", "/auth/callback")
* .https()
* .build();
* }</pre>
*/
public static Builder keycloak(String serverUrl, String realm,
String clientId, String clientSecret,
String redirectUri) {
String base = serverUrl.endsWith("/") ? serverUrl.substring(0, serverUrl.length() - 1) : serverUrl;
String issuer = base + "/realms/" + realm;
return new Builder(issuer, clientId, clientSecret, redirectUri)
.rolesClaimPath("realm_access.roles"); // Keycloak default
}
// -- Helpers --------------------------------------------------------------
private static String require(String v, String name) {
if (v == null || v.isBlank())
throw new IllegalArgumentException("OidcConfig: " + name + " is required");
return v;
}
private static String env(String key) {
String v = System.getenv(key);
if (v == null || v.isBlank())
throw new IllegalArgumentException("Missing required env var: " + key);
return v;
}
private static String envOr(String key, String def) {
String v = System.getenv(key);
return (v != null && !v.isBlank()) ? v : def;
}
// -- Builder --------------------------------------------------------------
public static final class Builder {
private final String issuer;
private final String clientId;
private final String clientSecret;
private final String redirectUri;
private String scopes = "openid profile email";
private String routePrefix = "/auth";
private String selfScheme = "http";
private String rolesClaimPath = "realm_access.roles";
private String scopeClaimPaths = "scope,scp";
private String algorithm = "RS256";
private String postLogoutRedirectUri = "/";
private OidcSessionStore sessionStore;
private boolean insecureTls = false;
private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
private String schemeName = null;
private Builder(String issuer, String clientId, String clientSecret, String redirectUri) {
this.issuer = issuer;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.redirectUri = redirectUri;
}
/** Override requested scopes (default: {@code openid profile email}). */
public Builder scopes(String scopes) { this.scopes = scopes; return this; }
/** Route prefix for login/callback/logout (default: {@code /auth}). */
public Builder routePrefix(String prefix) { this.routePrefix = prefix; return this; }
/** Scheme used when resolving self-relative redirect URIs (default: {@code http}). */
public Builder selfScheme(String scheme) { this.selfScheme = scheme; return this; }
/** Shorthand for {@code selfScheme("https")}. */
public Builder https() { return selfScheme("https"); }
/** Dot-separated path to the roles array in JWT claims (default: {@code realm_access.roles}). */
public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
/** Comma-separated claim paths used to resolve OAuth2 scopes (default: {@code scope,scp}). */
public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; }
/** JWS algorithm (default: {@code RS256}). */
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
/** Where to redirect after logout (default: {@code /}). */
public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
/** Custom session store (default: {@link InMemoryOidcSessionStore}). */
public Builder sessionStore(OidcSessionStore store) { this.sessionStore = store; return this; }
/**
* Disables TLS certificate verification for all HTTP calls made by this extension.
* <b>Only use in development with self-signed certificates — never in production.</b>
*/
public Builder insecureTls() { this.insecureTls = true; return this; }
/** Token endpoint client authentication method (default: {@link ClientAuthMethod#POST}). */
public Builder clientAuthMethod(ClientAuthMethod method) { this.clientAuthMethod = method; return this; }
/** Override the OpenAPI security scheme name (default: derived from the issuer URI). */
public Builder schemeName(String name) { this.schemeName = name; return this; }
public OidcConfig build() { return new OidcConfig(this); }
}
/**
* Derives a short, human-readable scheme name from the issuer URI.
* Takes the last non-empty path segment; falls back to the host.
*
* <p>Examples:
* <ul>
* <li>{@code https://keycloak.dev.home/realms/flashboard} → {@code "flashboard"}</li>
* <li>{@code https://auth.example.com} → {@code "auth.example.com"}</li>
* </ul>
*/
private static String deriveScheme(String issuer) {
try {
java.net.URI uri = new java.net.URI(issuer);
String path = uri.getPath();
if (path != null && !path.isEmpty()) {
String[] parts = path.split("/");
for (int i = parts.length - 1; i >= 0; i--) {
if (!parts[i].isEmpty()) return parts[i];
}
}
return uri.getHost();
} catch (Exception e) {
return "oidc";
}
}
}
@@ -1,337 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.ext.openapi.OpenApiContributor;
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.models.Request;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.*;
/**
* Full OIDC Authorization Code + PKCE flow for Flash.
*
* <p>At {@link #provide}, the extension:
* <ol>
* <li>Fetches the provider discovery document — fail-fast at startup.</li>
* <li>Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.</li>
* <li>Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
* and {@link ScopesAllowed}.</li>
* </ol>
*
* <p>At {@link #routes}, three routes are registered:
* <ul>
* <li>{@code GET {prefix}/login} — builds the authorization URL and redirects.</li>
* <li>{@code GET {prefix}/callback} — exchanges the code, creates a session, redirects.</li>
* <li>{@code POST {prefix}/logout} — invalidates the session, redirects to provider
* end-session endpoint (if available) or to {@link OidcConfig#postLogoutRedirectUri()}.</li>
* </ul>
*
* <pre>{@code
* // Keycloak
* app.install(new OidcExtension(
* OidcConfig.builder(
* "https://keycloak.example.com/realms/myrealm",
* "my-app", "secret", "/auth/callback")
* .rolesClaimPath("realm_access.roles")
* .build()));
*
* // Two providers / tenants on one server
* app.install(new OidcExtension(tenantAConfig))
* .install(new OidcExtension(tenantBConfig));
* }</pre>
*/
public class OidcExtension implements FlashExtension {
private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.oidc.policy");
private final OidcConfig config;
// Initialized in provide(), used in routes() — private to this extension instance.
private OidcProviderMetadata meta;
private OidcStateStore stateStore;
private TokenClient tokenClient;
private JwtValidator validator;
private OidcMiddleware oidcMw;
public OidcExtension(OidcConfig config) {
this.config = config;
}
// ── Phase 1: services ─────────────────────────────────────────────────────
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
HttpClient http = buildHttpClient(config);
// Discover provider endpoints (blocking; fail fast at startup).
try {
meta = DiscoveryClient.fetch(config.issuer(), http);
} catch (Exception e) {
throw new IllegalStateException("OIDC discovery failed for issuer: " + config.issuer(), e);
}
validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http);
stateStore = new OidcStateStore();
tokenClient = new TokenClient(http, config);
oidcMw = new OidcMiddleware(validator, config, meta, tokenClient);
ctx.provide(OidcMiddleware.class, oidcMw);
ctx.provide(JwtValidator.class, validator);
ctx.addAnnotationProcessor(handlerClass -> {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
return policy != null ? List.of(MiddlewareNode.of(POLICY, oidcMw.policyMiddleware(policy))) : List.of();
});
ctx.onReady(() -> registerRoutes(app, ctx));
}
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
String prefix = config.routePrefix();
// ── GET {prefix}/login ────────────────────────────────────────────────
// Builds the provider authorization URL with PKCE + state and redirects.
// Optional query param: ?redirect={relative-url} (default: /)
app.get(prefix + "/login", (req, res) -> {
String verifier = PkceUtils.generateVerifier();
String challenge = PkceUtils.computeChallenge(verifier);
String state = UUID.randomUUID().toString();
String nonce = UUID.randomUUID().toString();
String redirect = req.query("redirect");
if (redirect == null || !redirect.startsWith("/")) redirect = "/";
stateStore.put(state, redirect, verifier, nonce);
String authUrl = meta.authorizationEndpoint()
+ "?response_type=code"
+ "&client_id=" + enc(config.clientId())
+ "&redirect_uri=" + enc(absoluteRedirectUri(req))
+ "&scope=" + enc(config.scopes())
+ "&state=" + state
+ "&nonce=" + enc(nonce)
+ "&code_challenge=" + challenge
+ "&code_challenge_method=S256";
res.redirect(authUrl);
return null;
});
// ── GET {prefix}/callback ─────────────────────────────────────────────
// Validates state, exchanges code for tokens, creates session, redirects.
app.get(prefix + "/callback", (req, res) -> {
String error = req.query("error");
if (error != null) {
res.status(400);
return "Authentication error: " + error
+ (req.query("error_description") != null
? "" + req.query("error_description") : "");
}
String code = req.query("code");
String state = req.query("state");
OidcStateStore.Entry entry = stateStore.consumeAndRemove(state).orElse(null);
if (entry == null) {
res.status(400);
return "Invalid or expired state parameter";
}
OidcTokenResponse tokens = tokenClient.exchangeCode(
meta.tokenEndpoint(), code, absoluteRedirectUri(req), entry.codeVerifier());
// Validate ID token: signature + iss + aud + exp + iat + sub + nonce (OIDC Core §3.1.3.7)
if (tokens.idToken() != null) {
try {
validator.validateIdToken(tokens.idToken(), entry.nonce());
} catch (OidcValidationException e) {
res.status(400);
return "ID token validation failed: " + e.getMessage();
}
}
Map<String, Object> claims = mergeClaims(tokens);
OidcSession session = new OidcSession(
UUID.randomUUID().toString(),
tokens.accessToken(), tokens.idToken(), tokens.refreshToken(),
Instant.now().plusSeconds(tokens.expiresIn()), claims);
config.sessionStore().save(session);
res.header("Set-Cookie", sessionCookie(session.id()))
.redirect(entry.originalUrl());
return null;
});
// ── POST {prefix}/logout ──────────────────────────────────────────────
// Invalidates the local session and redirects to end_session_endpoint.
app.post(prefix + "/logout", (req, res) -> {
String sessionId = OidcMiddleware.cookieValue(req, "oidc_session");
String idTokenHint = null;
if (sessionId != null) {
OidcSession session = config.sessionStore().find(sessionId).orElse(null);
if (session != null) idTokenHint = session.idToken();
config.sessionStore().delete(sessionId);
}
String clearCookie = "oidc_session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax";
String location;
if (meta.endSessionEndpoint() != null) {
String postLogout = absoluteSelf(req, config.postLogoutRedirectUri());
StringBuilder url = new StringBuilder(meta.endSessionEndpoint())
.append("?post_logout_redirect_uri=").append(enc(postLogout));
if (idTokenHint != null)
url.append("&id_token_hint=").append(enc(idTokenHint));
location = url.toString();
} else {
location = config.postLogoutRedirectUri();
}
res.header("Set-Cookie", clearCookie).redirect(location);
return null;
});
// Register OpenAPI security scheme if flash-ext-openapi is on the classpath.
try {
OpenApiIntegration.register(ctx, config, meta);
} catch (NoClassDefFoundError ignored) {
// flash-ext-openapi not available — OpenAPI integration disabled
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Merges claims from both the access token and the ID token.
* ID token values win on conflict so that verified identity claims are authoritative.
*/
private static Map<String, Object> mergeClaims(OidcTokenResponse tokens) {
Map<String, Object> merged = new HashMap<>();
if (tokens.accessToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
if (tokens.idToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
return Map.copyOf(merged);
}
/**
* Builds an {@link HttpClient}. If {@link OidcConfig#insecureTls()} is set,
* installs a trust-all {@link SSLContext} that accepts any certificate.
* <b>Only safe for development with self-signed certificates.</b>
*/
private static HttpClient buildHttpClient(OidcConfig config) {
if (!config.insecureTls()) return HttpClient.newHttpClient();
try {
TrustManager[] trustAll = { new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
public void checkClientTrusted(X509Certificate[] c, String a) {}
public void checkServerTrusted(X509Certificate[] c, String a) {}
}};
SSLContext sslCtx = SSLContext.getInstance("TLS");
sslCtx.init(null, trustAll, new SecureRandom());
return HttpClient.newBuilder().sslContext(sslCtx).build();
} catch (Exception e) {
throw new IllegalStateException("Failed to create trust-all SSLContext", e);
}
}
private String absoluteRedirectUri(Request req) {
return absoluteSelf(req, config.redirectUri());
}
private String absoluteSelf(Request req, String uri) {
if (!uri.startsWith("/")) return uri;
return OidcMiddleware.selfOrigin(req, config.selfScheme()) + uri;
}
private static String enc(String v) {
return URLEncoder.encode(v, StandardCharsets.UTF_8);
}
private static String sessionCookie(String id) {
return "oidc_session=" + id + "; HttpOnly; Path=/; SameSite=Lax";
}
/**
* Loaded lazily so that {@code flash-ext-openapi} classes are only resolved at
* runtime when {@link OpenApiContributorRegistry} is actually on the classpath.
*/
private static final class OpenApiIntegration {
static void register(FlashContext ctx,
OidcConfig config, OidcProviderMetadata meta) {
ctx.find(OpenApiContributorRegistry.class)
.ifPresent(registry -> registry.add(new OpenApiContributor() {
@Override
public Map<String, Object> componentContributions() {
Map<String, String> scopesMap = new LinkedHashMap<>();
for (String s : config.scopes().split("\\s+")) {
if (!s.isBlank()) scopesMap.put(s, s);
}
Map<String, Object> flow = new LinkedHashMap<>();
flow.put("authorizationUrl", meta.authorizationEndpoint());
flow.put("tokenUrl", meta.tokenEndpoint());
flow.put("scopes", scopesMap);
Map<String, Object> scheme = new LinkedHashMap<>();
scheme.put("type", "oauth2");
scheme.put("flows", Map.of("authorizationCode", flow));
Map<String, Object> securitySchemes = new LinkedHashMap<>();
securitySchemes.put(config.schemeName(), scheme);
return Map.of("securitySchemes", securitySchemes);
}
@Override
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
OpenApiOperationContribution.Builder out =
OpenApiOperationContribution.builder();
List<String> operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
if (operationScopes != null) {
out.security(config.schemeName(), operationScopes);
}
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
if (policy == null || policy.optionalAuth()) return out.build();
out.response(401, OpenApiResponseContribution.of("Authentication required"));
String[] roles = policy.requiredRoles();
String[] scopes = policy.requiredScopes();
if (roles.length == 0 && scopes.length == 0) return out.build();
String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles);
String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes);
if (roleMessage != null && scopeMessage != null) {
out.response(403, OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage));
} else
out.response(403, OpenApiResponseContribution.of(Objects.requireNonNullElse(roleMessage, scopeMessage)));
return out.build();
}
}));
}
private static String roleRequiredMessage(String[] roles) {
if (roles.length == 1) return "\"" + roles[0] + "\" role required";
return "Roles \"" + String.join(", ", roles) + "\" are required";
}
private static String scopeRequiredMessage(String[] scopes) {
if (scopes.length == 1) return "\"" + scopes[0] + "\" scope required";
return "Scopes \"" + String.join(", ", scopes) + "\" are required";
}
}
}
@@ -1,550 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.Response;
import dev.relism.flash.models.Request;
import dev.relism.flash.routing.Middleware;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Request-level OIDC middleware. Exposed in the {@link FlashContext}
* for manual use on lambda routes; injected automatically for handlers annotated with
* {@link Authenticated}, {@link RolesAllowed} or {@link ScopesAllowed}.
*
* <p>Resolution order on each request:
* <ol>
* <li>{@code Authorization: Bearer ...} header — validated via JWKS ({@link JwtValidator}).</li>
* <li>{@code oidc_session} cookie — looked up in {@link OidcSessionStore}; transparently
* refreshed if the access token is expired.</li>
* <li>Browser clients (no {@code Accept: application/json}) → redirect to
* {@code {routePrefix}/login?redirect={path}}.</li>
* <li>API clients → 401.</li>
* </ol>
*
* <pre>{@code
* // Manual use on a lambda route:
* OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
* app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), oidc.protect());
* app.delete("/admin/users/{id}", handler, oidc.requireRole("admin"));
* }</pre>
*/
public class OidcMiddleware {
private static final String BEARER = "Bearer";
private final JwtValidator validator;
private final OidcConfig config;
private final OidcProviderMetadata meta;
private final TokenClient tokenClient;
private final String[] roleClaimPathParts;
private final String[][] scopeClaimPathParts;
OidcMiddleware(JwtValidator validator, OidcConfig config,
OidcProviderMetadata meta, TokenClient tokenClient) {
this.validator = validator;
this.config = config;
this.meta = meta;
this.tokenClient = tokenClient;
this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath());
this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths());
}
// -- Public API -----------------------------------------------------------
/** The single configured claim path used by every transport for role checks. */
public String rolesClaimPath() { return config.rolesClaimPath(); }
/**
* Validates the bearer token or session cookie. Browser clients are redirected
* to the login page on failure; API clients receive 401.
*/
public Middleware protect() {
return protect(null);
}
/**
* Like {@link #protect()}, but a 401 challenge also carries {@code resource_metadata}
* (RFC 9728 §5.1), resolved against this request's own scheme/host exactly like
* {@link OidcExtension}'s redirect URIs. {@code resourceMetadataPath} is an absolute path
* (e.g. {@code "/.well-known/oauth-protected-resource/mcp"}); pass {@code null} for plain
* challenges. Used by {@code flash-ext-mcp} to make its Protected Resource Metadata
* document discoverable straight from the {@code WWW-Authenticate} header, per the MCP
* Authorization spec.
*/
public Middleware protect(String resourceMetadataPath) {
return next -> (req, res) -> {
Map<String, Object> claims = resolve(req, res, resourceMetadataPath);
if (claims == null) return null; // redirect already written
ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/** OIDC issuer this middleware validates tokens against — the {@code iss} claim it enforces. */
public String issuer() { return config.issuer(); }
/** Scheme used to build this app's own absolute URLs — see {@link OidcConfig#selfScheme()}. */
public String selfScheme() { return config.selfScheme(); }
/**
* Silently populates {@link ClaimsHolder} if a valid bearer token or session cookie
* is present, but never rejects or redirects unauthenticated requests. Use this on
* public routes that want to personalise the response when the user happens to be
* logged in (e.g. showing a username on a landing page).
*
* <pre>{@code
* app.get("/", handler, oidc.optional());
* // Inside handler: ClaimsHolder.user() is non-null iff the user is logged in.
* }</pre>
*/
public Middleware optional() {
return next -> (req, res) -> {
Map<String, Object> claims = resolveQuiet(req);
if (claims != null) ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/**
* Compiled authorization policy path used by annotation-driven mounting.
* The policy is immutable and built once at boot.
*/
public Middleware authorize(OidcAuthPolicy policy) {
if (policy.optionalAuth()) return optional();
return next -> (req, res) -> {
Map<String, Object> claims = resolve(req, res);
if (claims == null) return null;
enforcePolicy(claims, policy, res);
ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/**
* Like {@link #protect()} but also enforces that the caller holds at least one
* of the given roles (OR semantics). Roles are extracted via
* {@link OidcConfig#rolesClaimPath()}.
*/
public Middleware requireRole(String... roles) {
return authorize(OidcAuthPolicy.rolesAny(roles));
}
/**
* Requires all listed scopes to be present in the token.
* Scopes are resolved from configured claim paths (default: {@code scope,scp}).
*/
public Middleware requireScopes(String... scopes) {
return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL));
}
/**
* Requires at least one of the listed scopes to be present in the token.
* Scopes are resolved from configured claim paths (default: {@code scope,scp}).
*/
public Middleware requireAnyScope(String... scopes) {
return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY));
}
// -- Package-private: AnnotationProcessor hooks ---------------------------
Middleware authenticatedMiddleware() { return protect(); }
Middleware optionalMiddleware() { return optional(); }
Middleware rolesMiddleware(String[] required) { return requireRole(required); }
Middleware scopesMiddleware(String[] required, ScopesAllowed.Match match) {
return authorize(OidcAuthPolicy.scopes(required, match));
}
Middleware policyMiddleware(OidcAuthPolicy policy) { return authorize(policy); }
// -- Internals ------------------------------------------------------------
/**
* Like {@link #resolve} but never redirects or throws — returns {@code null} silently
* when no valid credentials are present. Used by {@link #optional()}.
*/
private Map<String, Object> resolveQuiet(Request req) {
String bearerToken = extractBearerToken(req.header("Authorization"));
if (bearerToken != null) {
try {
return validator.validate(bearerToken);
} catch (Exception ignored) {
return null;
}
}
String sessionId = cookieValue(req, "oidc_session");
if (sessionId != null) {
Optional<OidcSession> found = config.sessionStore().find(sessionId);
if (found.isPresent()) {
OidcSession session = found.get();
if (!session.isAccessTokenExpired())
return session.claims();
if (session.refreshToken() != null) {
try {
OidcSession refreshed = doRefresh(session);
config.sessionStore().save(refreshed);
return refreshed.claims();
} catch (Exception ignored) { }
}
config.sessionStore().delete(sessionId);
}
}
return null;
}
/**
* Returns claims on success, or {@code null} if a redirect was already written to
* {@code res}. Throws {@link HttpException} 401/403 for API clients.
*/
private Map<String, Object> resolve(Request req, Response res) {
return resolve(req, res, null);
}
private Map<String, Object> resolve(Request req, Response res, String resourceMetadataPath) {
// 1. Bearer token
String bearerToken = extractBearerToken(req.header("Authorization"));
if (bearerToken != null) {
try {
return validator.validate(bearerToken);
} catch (HttpException e) {
res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath));
throw e;
}
}
// 2. Session cookie
String sessionId = cookieValue(req, "oidc_session");
if (sessionId != null) {
Optional<OidcSession> found = config.sessionStore().find(sessionId);
if (found.isPresent()) {
OidcSession session = found.get();
if (!session.isAccessTokenExpired())
return session.claims();
// Access token expired — try silent refresh
if (session.refreshToken() != null) {
try {
OidcSession refreshed = doRefresh(session);
config.sessionStore().save(refreshed);
return refreshed.claims();
} catch (Exception ignored) {
// Refresh failed — fall through to re-authenticate
}
}
config.sessionStore().delete(sessionId);
}
}
// 3. No valid credentials
String accept = req.header("Accept");
if (accept != null && accept.contains("application/json")) {
res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath));
throw HttpException.unauthorized();
}
// Browser — redirect to login, preserving the original URL in state
String loginUrl = config.routePrefix() + "/login?redirect="
+ URLEncoder.encode(req.path(), StandardCharsets.UTF_8);
res.redirect(loginUrl);
return null;
}
private OidcSession doRefresh(OidcSession old) throws Exception {
OidcTokenResponse tokens = tokenClient.refresh(
meta.tokenEndpoint(), old.refreshToken());
Map<String, Object> claims = mergeRefreshedClaims(tokens, old);
return new OidcSession(
old.id(),
tokens.accessToken(),
tokens.idToken() != null ? tokens.idToken() : old.idToken(),
tokens.refreshToken() != null ? tokens.refreshToken() : old.refreshToken(),
Instant.now().plusSeconds(tokens.expiresIn()),
claims
);
}
private void enforcePolicy(Map<String, Object> claims, OidcAuthPolicy policy, Response res) {
checkRoles(claims, policy.requiredRoles());
checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res);
}
private void checkRoles(Map<String, Object> claims, String[] required) {
if (required.length == 0) return;
if (rolesAllowed(claims, required)) return;
throw HttpException.forbidden();
}
private void checkScopes(Map<String, Object> claims, String[] required, ScopesAllowed.Match match,
Response res) {
if (required.length == 0) return;
if (scopesAllowed(claims, required, match)) return;
res.header("WWW-Authenticate", insufficientScopeChallenge(required));
throw HttpException.forbidden();
}
static String extractBearerToken(String authorizationHeader) {
if (authorizationHeader == null) return null;
int len = authorizationHeader.length();
int start = 0;
while (start < len && Character.isWhitespace(authorizationHeader.charAt(start))) start++;
int schemeEnd = start + BEARER.length();
if (schemeEnd > len || !authorizationHeader.regionMatches(true, start, BEARER, 0, BEARER.length())) {
return null;
}
if (schemeEnd == len || !Character.isWhitespace(authorizationHeader.charAt(schemeEnd))) {
return null;
}
int tokenStart = schemeEnd;
while (tokenStart < len && Character.isWhitespace(authorizationHeader.charAt(tokenStart))) tokenStart++;
if (tokenStart >= len) return null;
int tokenEnd = len;
while (tokenEnd > tokenStart && Character.isWhitespace(authorizationHeader.charAt(tokenEnd - 1))) tokenEnd--;
return tokenEnd > tokenStart ? authorizationHeader.substring(tokenStart, tokenEnd) : null;
}
String bearerChallenge() {
return bearerChallenge(null, null);
}
private String bearerChallenge(Request req, String resourceMetadataPath) {
String base = BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
if (resourceMetadataPath == null) return base;
return base + ", resource_metadata=\"" + quoted(absoluteSelf(req, resourceMetadataPath)) + "\"";
}
String invalidTokenChallenge() {
return invalidTokenChallenge(null, null);
}
private String invalidTokenChallenge(Request req, String resourceMetadataPath) {
return bearerChallenge(req, resourceMetadataPath) + ", error=\"invalid_token\"";
}
String insufficientScopeChallenge(String[] requiredScopes) {
return bearerChallenge() + ", error=\"insufficient_scope\", scope=\""
+ quoted(spaceDelimited(requiredScopes)) + "\"";
}
private String absoluteSelf(Request req, String path) {
if (!path.startsWith("/")) return path;
return selfOrigin(req, config.selfScheme()) + path;
}
/**
* {@code scheme://host} clients actually reach this app on — the basis for every absolute
* URL it publishes about itself (OAuth2 {@code redirect_uri}, the RFC 9728 resource
* identifier and the {@code resource_metadata} challenge). Behind a reverse proxy the
* request's own {@code Host} is the upstream address the proxy dialled, so
* {@code X-Forwarded-Host}/{@code -Proto} win whenever present: without them the app would
* name an address no client can resolve, and OAuth2 discovery fails with no error anyone
* can trace back to here. Trusted unconditionally — a caller able to reach this app without
* passing the proxy can do worse than spoof a self URL.
*/
public static String selfOrigin(Request req, String fallbackScheme) {
String forwardedHost = req.header("X-Forwarded-Host");
if (forwardedHost == null) return fallbackScheme + "://" + req.header("Host");
String forwardedProto = req.header("X-Forwarded-Proto");
return (forwardedProto != null ? forwardedProto : fallbackScheme) + "://" + forwardedHost;
}
private static String spaceDelimited(String[] values) {
if (values == null || values.length == 0) return "";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < values.length; i++) {
if (i > 0) sb.append(' ');
sb.append(values[i]);
}
return sb.toString();
}
private static String quoted(String value) {
StringBuilder out = new StringBuilder(value.length() + 8);
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '"' || c == '\\') out.append('\\');
out.append(c);
}
return out.toString();
}
boolean rolesAllowed(Map<String, Object> claims, String[] required) {
Object actual = valueAtPath(claims, roleClaimPathParts);
if (actual == null) return false;
for (String role : required) {
if (containsToken(actual, role)) return true;
}
return false;
}
boolean scopesAllowed(Map<String, Object> claims, String[] required, ScopesAllowed.Match match) {
if (match == ScopesAllowed.Match.ALL) {
for (String scope : required) {
if (!hasScope(claims, scope)) return false;
}
return true;
}
for (String scope : required) {
if (hasScope(claims, scope)) return true;
}
return false;
}
private boolean hasScope(Map<String, Object> claims, String scope) {
for (String[] pathParts : scopeClaimPathParts) {
Object value = valueAtPath(claims, pathParts);
if (value != null && containsToken(value, scope)) return true;
}
return false;
}
private static Object valueAtPath(Map<String, Object> claims, String[] pathParts) {
Object current = claims;
for (String part : pathParts) {
if (!(current instanceof Map<?, ?> map)) return null;
current = map.get(part);
if (current == null) return null;
}
return current;
}
private static boolean containsToken(Object source, String token) {
if (source instanceof String s) return containsDelimitedToken(s, token);
if (source instanceof List<?> list) {
for (Object item : list) {
if (item == null) continue;
if (tokenEquals(item.toString(), token)) return true;
}
return false;
}
if (source instanceof Object[] arr) {
for (Object item : arr) {
if (item == null) continue;
if (tokenEquals(item.toString(), token)) return true;
}
return false;
}
return tokenEquals(source.toString(), token);
}
private static boolean containsDelimitedToken(String value, String token) {
int len = value.length();
int i = 0;
while (i < len) {
while (i < len && isScopeDelimiter(value.charAt(i))) i++;
int start = i;
while (i < len && !isScopeDelimiter(value.charAt(i))) i++;
int end = i;
if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) {
return true;
}
}
return false;
}
private static boolean tokenEquals(String value, String token) {
int start = 0;
int end = value.length();
while (start < end && Character.isWhitespace(value.charAt(start))) start++;
while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--;
return end - start == token.length() && value.regionMatches(start, token, 0, token.length());
}
private static boolean isScopeDelimiter(char c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
}
private static String[] splitClaimPath(String path) {
if (path == null || path.isBlank()) {
throw new IllegalStateException("OIDC claim path cannot be blank");
}
List<String> parts = new ArrayList<>(4);
int start = 0;
int len = path.length();
for (int i = 0; i <= len; i++) {
if (i == len || path.charAt(i) == '.') {
String p = path.substring(start, i).trim();
if (!p.isEmpty()) parts.add(p);
start = i + 1;
}
}
if (parts.isEmpty()) {
throw new IllegalStateException("OIDC claim path cannot be blank");
}
return parts.toArray(String[]::new);
}
private static String[][] splitClaimPaths(String paths) {
String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths;
List<String[]> out = new ArrayList<>(4);
int start = 0;
int len = source.length();
for (int i = 0; i <= len; i++) {
if (i == len || source.charAt(i) == ',') {
String raw = source.substring(start, i).trim();
if (!raw.isEmpty()) out.add(splitClaimPath(raw));
start = i + 1;
}
}
if (out.isEmpty()) {
return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") };
}
return out.toArray(String[][]::new);
}
private static Map<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) {
Map<String, Object> merged = new HashMap<>();
// Fall back to old claims first, then overlay fresh token claims
merged.putAll(old.claims());
if (tokens.accessToken() != null)
merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
if (tokens.idToken() != null)
merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
return Map.copyOf(merged);
}
// -- Shared cookie utility (also used by OidcExtension) -------------------
static String cookieValue(Request req, String name) {
String header = req.header("Cookie");
if (header == null || header.isBlank()) return null;
int len = header.length();
int start = 0;
while (start < len) {
int semi = header.indexOf(';', start);
int end = semi < 0 ? len : semi;
int eq = header.indexOf('=', start);
if (eq > start && eq < end) {
int ns = start, ne = eq;
while (ns < ne && header.charAt(ns) == ' ') ns++;
while (ne > ns && header.charAt(ne-1) == ' ') ne--;
if (ne - ns == name.length() && header.regionMatches(ns, name, 0, name.length()))
return header.substring(eq + 1, end).strip();
}
start = end + 1;
}
return null;
}
}
@@ -1,15 +0,0 @@
package dev.relism.flash.ext.oidc;
/**
* OIDC provider endpoints discovered from {@code {issuer}/.well-known/openid-configuration}.
*
* <p>{@link #endSessionEndpoint()} may be {@code null} — not all providers expose it
* (e.g. some Authelia configurations omit it).
*/
public record OidcProviderMetadata(
String authorizationEndpoint,
String tokenEndpoint,
String userinfoEndpoint,
String jwksUri,
String endSessionEndpoint // nullable
) {}
@@ -1,47 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.time.Instant;
import java.util.Map;
/**
* An authenticated user's OIDC session — persisted in {@link OidcSessionStore} and
* looked up via the {@code oidc_session} cookie on every request.
*
* <p>Sessions are immutable; a refreshed access token produces a new instance
* that replaces the old one in the store (same {@link #id()}).
*/
public final class OidcSession {
private final String id;
private final String accessToken;
private final String idToken;
private final String refreshToken; // may be null
private final Instant accessTokenExpiresAt;
private final Map<String, Object> claims; // decoded from id_token
public OidcSession(String id, String accessToken, String idToken,
String refreshToken, Instant accessTokenExpiresAt,
Map<String, Object> claims) {
this.id = id;
this.accessToken = accessToken;
this.idToken = idToken;
this.refreshToken = refreshToken;
this.accessTokenExpiresAt = accessTokenExpiresAt;
this.claims = Map.copyOf(claims);
}
/**
* Returns {@code true} if the access token has expired or will expire within
* the next 30 seconds (eager refresh to avoid mid-request expiry).
*/
public boolean isAccessTokenExpired() {
return Instant.now().isAfter(accessTokenExpiresAt.minusSeconds(30));
}
public String id() { return id; }
public String accessToken() { return accessToken; }
public String idToken() { return idToken; }
public String refreshToken() { return refreshToken; }
public Instant accessTokenExpiresAt() { return accessTokenExpiresAt; }
public Map<String, Object> claims() { return claims; }
}
@@ -1,14 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.Optional;
/**
* Backing store for {@link OidcSession} objects. The default implementation is
* {@link InMemoryOidcSessionStore}; supply a custom one via
* {@link OidcConfig.Builder#sessionStore(OidcSessionStore)} for Redis, JDBC, etc.
*/
public interface OidcSessionStore {
void save(OidcSession session);
Optional<OidcSession> find(String sessionId);
void delete(String sessionId);
}
@@ -1,39 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Short-lived store mapping state nonces → (original URL, PKCE verifier).
*
* <p>Entries expire after {@value #TTL_SECONDS} seconds. Cleanup runs on every
* access to prevent unbounded growth without needing a background thread.
*/
final class OidcStateStore {
static final int TTL_SECONDS = 600; // 10 minutes
record Entry(String originalUrl, String codeVerifier, String nonce, Instant expiresAt) {}
private final ConcurrentHashMap<String, Entry> store = new ConcurrentHashMap<>();
void put(String state, String originalUrl, String codeVerifier, String nonce) {
cleanup();
store.put(state, new Entry(originalUrl, codeVerifier, nonce,
Instant.now().plusSeconds(TTL_SECONDS)));
}
/** Atomically retrieves and removes the entry; returns empty if absent or expired. */
Optional<Entry> consumeAndRemove(String nonce) {
cleanup();
Entry e = store.remove(nonce);
if (e == null || Instant.now().isAfter(e.expiresAt())) return Optional.empty();
return Optional.of(e);
}
private void cleanup() {
Instant now = Instant.now();
store.entrySet().removeIf(kv -> now.isAfter(kv.getValue().expiresAt()));
}
}
@@ -1,10 +0,0 @@
package dev.relism.flash.ext.oidc;
/** Parsed response from an OAuth2 token endpoint. Package-private — internal use only. */
record OidcTokenResponse(
String accessToken,
String idToken, // may be null on refresh if provider omits it
String refreshToken, // may be null
int expiresIn,
int refreshExpiresIn
) {}
@@ -1,237 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;
/**
* Type-safe view over the JWT claims stored in {@link ClaimsHolder}.
*
* <p>Obtainable from any protected context via {@link ClaimsHolder#user()}.
* Class-based handlers that extend the {@code SessionHandler} hierarchy already
* have a provisioned DB user in {@code currentUser}; {@code OidcUser} complements
* that by giving access to the raw OIDC claims when needed, and is the primary
* API for lambda routes.
*
* <pre>{@code
* // Lambda route (OidcMiddleware injected):
* app.get("/api/whoami", (req, res) -> {
* OidcUser u = ClaimsHolder.user();
* return Map.of("sub", u.sub(), "email", u.email(), "roles", u.roles("realm_access.roles"), "scopes", u.scopes());
* }, oidcMw.protect());
*
* // Class-based handler (currentUser is the DB entity; oidcUser() for raw claims):
* protected Object handleAuthenticated(Request req, Response res) throws Exception {
* OidcUser u = oidcUser(); // same as ClaimsHolder.user()
* return json(res, currentUser); // DB entity — provisioned from OIDC sub
* }
* }</pre>
*/
public final class OidcUser {
private final Map<String, Object> claims;
OidcUser(Map<String, Object> claims) {
this.claims = claims;
}
// ── Common OIDC standard claims ───────────────────────────────────────────
/** Subject identifier — unique, stable user ID issued by the provider. */
public String sub() { return str("sub"); }
/** User's email address ({@code email} claim). */
public String email() { return str("email"); }
/** Human-readable username ({@code preferred_username} claim). */
public String username() { return str("preferred_username"); }
/** Full display name ({@code name} claim). */
public String name() { return str("name"); }
// ── Roles ────────────────────────────────────────────────────────────────
/**
* Extracts the roles list by traversing a dot-separated claim path.
*
* <p>Example paths:
* <ul>
* <li>{@code "realm_access.roles"} — Keycloak realm roles</li>
* <li>{@code "resource_access.my-client.roles"} — Keycloak client roles</li>
* <li>{@code "groups"} — Authelia / generic IdPs</li>
* </ul>
*
* @return list of role strings, or an empty list if the path doesn't exist
*/
@SuppressWarnings("unchecked")
public List<String> roles(String claimPath) {
String[] parts = claimPath.split("\\.");
Object current = claims;
for (String part : parts) {
if (!(current instanceof Map<?, ?> m)) return List.of();
current = m.get(part);
}
if (current instanceof List<?> list)
return list.stream().map(Object::toString).toList();
return List.of();
}
/** Returns {@code true} if the user holds {@code role} at the given claim path. */
public boolean hasRole(String claimPath, String role) {
return roles(claimPath).contains(role);
}
// -- Scopes ---------------------------------------------------------------
/**
* Resolves OAuth2 scopes from standard OIDC/OAuth claims using fallback order:
* {@code scope} then {@code scp}. Supports both space-separated string and list forms.
*/
public List<String> scopes() {
return scopes("scope,scp");
}
/**
* Resolves scopes from comma-separated claim paths (example: {@code "scope,scp,permissions.scopes"}).
*/
public List<String> scopes(String claimPaths) {
List<String> out = new ArrayList<>();
for (String[] path : splitClaimPaths(claimPaths)) {
Object value = valueAtPath(path);
if (value == null) continue;
if (value instanceof String s) {
appendDelimitedTokens(out, s);
continue;
}
if (value instanceof List<?> list) {
for (Object item : list) {
if (item == null) continue;
String token = item.toString().trim();
if (!token.isEmpty()) out.add(token);
}
continue;
}
String token = value.toString().trim();
if (!token.isEmpty()) out.add(token);
}
return out.isEmpty() ? List.of() : List.copyOf(out);
}
/** Returns {@code true} if the user has {@code scope}, searching default claim paths {@code scope,scp}. */
public boolean hasScope(String scope) {
return hasScope("scope,scp", scope);
}
/** Returns {@code true} if the user has {@code scope} in any of {@code claimPaths}. */
public boolean hasScope(String claimPaths, String scope) {
if (scope == null || scope.isBlank()) return false;
String target = scope.trim();
for (String[] path : splitClaimPaths(claimPaths)) {
Object value = valueAtPath(path);
if (value == null) continue;
if (value instanceof String s && containsDelimitedToken(s, target)) return true;
if (value instanceof List<?> list) {
for (Object item : list) {
if (item == null) continue;
if (target.equals(item.toString().trim())) return true;
}
continue;
}
if (target.equals(value.toString().trim())) return true;
}
return false;
}
// ── Arbitrary claim access ─────────────────────────────────────────────
/**
* Returns the value of any claim, cast to {@code T}.
*
* @throws ClassCastException if the stored value is not assignable to {@code type}
*/
public <T> T claim(String key, Class<T> type) {
return type.cast(claims.get(key));
}
/** Returns the raw claim value, or {@code null} if absent. */
public Object claim(String key) { return claims.get(key); }
/** Escape hatch — returns the full unmodified claims map. */
public Map<String, Object> claims() { return claims; }
// ── Internals ─────────────────────────────────────────────────────────
private String str(String key) {
Object v = claims.get(key);
return v != null ? v.toString() : null;
}
private Object valueAtPath(String[] path) {
Object current = claims;
for (String part : path) {
if (!(current instanceof Map<?, ?> m)) return null;
current = m.get(part);
if (current == null) return null;
}
return current;
}
private static String[][] splitClaimPaths(String claimPaths) {
String source = (claimPaths == null || claimPaths.isBlank()) ? "scope,scp" : claimPaths;
List<String[]> out = new ArrayList<>(4);
int start = 0;
int len = source.length();
for (int i = 0; i <= len; i++) {
if (i == len || source.charAt(i) == ',') {
String raw = source.substring(start, i).trim();
if (!raw.isEmpty()) out.add(splitPath(raw));
start = i + 1;
}
}
return out.isEmpty() ? new String[][]{ splitPath("scope"), splitPath("scp") } : out.toArray(String[][]::new);
}
private static String[] splitPath(String path) {
List<String> out = new ArrayList<>(4);
int start = 0;
int len = path.length();
for (int i = 0; i <= len; i++) {
if (i == len || path.charAt(i) == '.') {
String raw = path.substring(start, i).trim();
if (!raw.isEmpty()) out.add(raw);
start = i + 1;
}
}
return out.isEmpty() ? new String[]{ path } : out.toArray(String[]::new);
}
private static void appendDelimitedTokens(List<String> target, String source) {
int len = source.length();
int i = 0;
while (i < len) {
while (i < len && isDelimiter(source.charAt(i))) i++;
int start = i;
while (i < len && !isDelimiter(source.charAt(i))) i++;
if (i > start) target.add(source.substring(start, i));
}
}
private static boolean containsDelimitedToken(String source, String token) {
int len = source.length();
int i = 0;
while (i < len) {
while (i < len && isDelimiter(source.charAt(i))) i++;
int start = i;
while (i < len && !isDelimiter(source.charAt(i))) i++;
int end = i;
if (end > start && end - start == token.length() && source.regionMatches(start, token, 0, token.length())) {
return true;
}
}
return false;
}
private static boolean isDelimiter(char c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
}
}
@@ -1,14 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.exceptions.HttpException;
/**
* Thrown when OIDC token validation fails (signature, claims, nonce, expiry, etc.).
* Distinct from {@link HttpException}: this signals a protocol-level
* failure, not an HTTP response — callers decide the appropriate status code.
*/
public final class OidcValidationException extends RuntimeException {
public OidcValidationException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -1,36 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
/**
* PKCE (RFC 7636) utilities: code verifier generation and S256 challenge computation.
* Package-private — used exclusively by {@link OidcExtension}.
*/
final class PkceUtils {
private static final SecureRandom RANDOM = new SecureRandom();
private PkceUtils() {}
/**
* Generates a cryptographically random code verifier (43 URL-safe characters,
* per RFC 7636 §4.1 — 32 bytes encoded as unpadded Base64URL).
*/
static String generateVerifier() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
/**
* Computes the S256 code challenge: {@code BASE64URL(SHA-256(ASCII(verifier)))}.
*/
static String computeChallenge(String verifier) throws Exception {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(verifier.getBytes(StandardCharsets.US_ASCII));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
}
@@ -1,32 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Restricts a handler to callers whose JWT contains at least one of the
* specified roles. Authentication is implicitly required — no need to combine
* with {@link Authenticated}.
*
* <p>Roles are read from the claim configured in {@link OidcConfig#rolesClaimPath()}
* (default: {@code "roles"}). Nested paths like {@code "realm_access.roles"} are
* supported with dot notation.
*
* <pre>{@code
* @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
* @RolesAllowed("admin")
* public class DeleteBlog extends JacksonHandler { ... }
*
* // Multiple accepted roles (OR semantics — any one role is sufficient):
* @RolesAllowed({"admin", "editor"})
* public class UpdateBlog extends JacksonHandler { ... }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface RolesAllowed {
/** One or more role names. Access is granted if the caller has any of them. */
String[] value();
}
@@ -1,45 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Restricts a handler to callers whose token carries the required OAuth2 scopes.
* Authentication is implicitly required.
*
* <p>Scopes are resolved from the configured claim paths in
* {@link OidcConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support
* both standard formats:
* <ul>
* <li>{@code scope}: space-separated string</li>
* <li>{@code scp}: string list (or string)</li>
* </ul>
*
* <pre>{@code
* @Route(method = HttpMethod.GET, path = "/api/orders")
* @ScopesAllowed("orders:read")
* public class ListOrders extends JacksonHandler { ... }
*
* @Route(method = HttpMethod.POST, path = "/api/orders")
* @ScopesAllowed(value = {"orders:write", "payments:write"}, match = ScopesAllowed.Match.ANY)
* public class CreateOrder extends JacksonHandler { ... }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ScopesAllowed {
/** Required scopes. */
String[] value();
/** Matching mode for {@link #value()}. */
Match match() default Match.ALL;
enum Match {
/** Any one required scope is sufficient. */
ANY,
/** All required scopes must be present. */
ALL
}
}
@@ -1,112 +0,0 @@
package dev.relism.flash.ext.oidc;
import net.minidev.json.JSONValue;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* HTTP client for OAuth2 token endpoint operations (pure HTTP, no SDK).
*
* <p>Supports two client authentication methods (RFC 6749 §2.3):
* <ul>
* <li>{@link ClientAuthMethod#POST} — credentials in form body ({@code client_secret_post})</li>
* <li>{@link ClientAuthMethod#BASIC} — credentials in {@code Authorization: Basic} header
* ({@code client_secret_basic})</li>
* </ul>
*/
final class TokenClient {
private final HttpClient http;
private final String clientId;
private final String clientSecret;
private final ClientAuthMethod authMethod;
TokenClient(HttpClient http, OidcConfig config) {
this.http = http;
this.clientId = config.clientId();
this.clientSecret = config.clientSecret();
this.authMethod = config.clientAuthMethod();
}
/** Authorization Code + PKCE exchange. */
OidcTokenResponse exchangeCode(String tokenEndpoint,
String code, String redirectUri,
String codeVerifier) throws Exception {
Map<String, String> params = new LinkedHashMap<>();
params.put("grant_type", "authorization_code");
params.put("code", code);
params.put("redirect_uri", redirectUri);
params.put("code_verifier", codeVerifier);
return post(tokenEndpoint, params);
}
/** Refresh token grant. */
OidcTokenResponse refresh(String tokenEndpoint, String refreshToken) throws Exception {
Map<String, String> params = new LinkedHashMap<>();
params.put("grant_type", "refresh_token");
params.put("refresh_token", refreshToken);
return post(tokenEndpoint, params);
}
// -- Internals ------------------------------------------------------------
private OidcTokenResponse post(String url, Map<String, String> params) throws Exception {
HttpRequest.Builder req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/x-www-form-urlencoded");
if (authMethod == ClientAuthMethod.BASIC) {
String creds = Base64.getEncoder().encodeToString(
(clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
req.header("Authorization", "Basic " + creds);
} else {
params.put("client_id", clientId);
params.put("client_secret", clientSecret);
}
HttpResponse<String> resp = http.send(
req.POST(HttpRequest.BodyPublishers.ofString(form(params))).build(),
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() < 200 || resp.statusCode() >= 300)
throw new IllegalStateException(
"Token endpoint [" + resp.statusCode() + "]: " + resp.body());
@SuppressWarnings("unchecked")
Map<String, Object> json = (Map<String, Object>) JSONValue.parse(resp.body());
return new OidcTokenResponse(
(String) json.get("access_token"),
(String) json.get("id_token"),
(String) json.get("refresh_token"),
numInt(json, "expires_in", 300),
numInt(json, "refresh_expires_in", 1800)
);
}
private static String form(Map<String, String> params) {
StringBuilder sb = new StringBuilder();
params.forEach((k, v) -> {
if (!sb.isEmpty()) sb.append('&');
sb.append(enc(k)).append('=').append(enc(v));
});
return sb.toString();
}
private static String enc(String v) {
return URLEncoder.encode(v, StandardCharsets.UTF_8);
}
private static int numInt(Map<String, Object> m, String key, int def) {
Object v = m.get(key);
return v instanceof Number n ? n.intValue() : def;
}
}
@@ -1,94 +0,0 @@
package dev.relism.flash.ext.oidc;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class OidcAuthPolicyTest {
static class PlainHandler {}
@Authenticated
static class AuthenticatedHandler {}
@Authenticated(optional = true)
static class OptionalHandler {}
@RolesAllowed({"admin", " editor ", "admin"})
static class RolesHandler {}
@ScopesAllowed(value = {"orders:write", " payments:write ", "orders:write"}, match = ScopesAllowed.Match.ANY)
static class ScopesHandler {}
@Authenticated
@RolesAllowed("admin")
@ScopesAllowed(value = {"orders:read", "payments:read"}, match = ScopesAllowed.Match.ALL)
static class CombinedHandler {}
@Authenticated(optional = true)
@ScopesAllowed("orders:read")
static class InvalidOptionalHandler {}
@Test
void compileFromAnnotations_noSecurityAnnotations_returnsNull() {
assertNull(OidcAuthPolicy.compileFromAnnotations(PlainHandler.class));
}
@Test
void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(AuthenticatedHandler.class);
assertNotNull(policy);
assertFalse(policy.optionalAuth());
assertEquals(0, policy.requiredRoles().length);
assertEquals(0, policy.requiredScopes().length);
}
@Test
void compileFromAnnotations_optionalAuth_createsOptionalPolicy() {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(OptionalHandler.class);
assertNotNull(policy);
assertTrue(policy.optionalAuth());
}
@Test
void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(CombinedHandler.class);
assertNotNull(policy);
assertFalse(policy.optionalAuth());
assertArrayEquals(new String[]{"admin"}, policy.requiredRoles());
assertArrayEquals(new String[]{"orders:read", "payments:read"}, policy.requiredScopes());
assertEquals(ScopesAllowed.Match.ALL, policy.scopeMatch());
}
@Test
void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(ScopesHandler.class);
assertNotNull(policy);
assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes());
assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch());
}
@Test
void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() {
assertThrows(IllegalStateException.class,
() -> OidcAuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class));
}
@Test
void openApiScopesFor_returnsScopesWhenPresent() {
assertEquals(List.of("orders:write", "payments:write"),
OidcAuthPolicy.openApiScopesFor(ScopesHandler.class));
}
@Test
void openApiScopesFor_rolesOnly_returnsEmptyList() {
assertEquals(List.of(), OidcAuthPolicy.openApiScopesFor(RolesHandler.class));
}
@Test
void openApiScopesFor_noSecurity_returnsNull() {
assertNull(OidcAuthPolicy.openApiScopesFor(PlainHandler.class));
}
}
@@ -1,72 +0,0 @@
package dev.relism.flash.ext.oidc;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class OidcMiddlewareAuthzTest {
private static OidcMiddleware middleware(String rolesPath, String scopePaths) {
OidcConfig cfg = OidcConfig.builder("https://idp.example.com", "client", "secret", "/auth/callback")
.rolesClaimPath(rolesPath)
.scopeClaimPaths(scopePaths)
.build();
return new OidcMiddleware(null, cfg, null, null);
}
@Test
void rolesAllowed_readsConfiguredNestedClaimPath() {
OidcMiddleware mw = middleware("realm_access.roles", "scope,scp");
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("user", "admin")));
assertTrue(mw.rolesAllowed(claims, new String[]{"admin"}));
assertFalse(mw.rolesAllowed(claims, new String[]{"ops"}));
}
@Test
void scopesAllowed_all_requiresEveryScope() {
OidcMiddleware mw = middleware("roles", "scope,scp");
Map<String, Object> claims = Map.of("scope", "openid profile orders:read");
assertTrue(mw.scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL));
assertFalse(mw.scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL));
}
@Test
void scopesAllowed_any_acceptsAnyConfiguredScopeSource() {
OidcMiddleware mw = middleware("roles", "scope,scp,permissions.scopes");
Map<String, Object> claims = Map.of(
"scp", List.of("payments:write"),
"permissions", Map.of("scopes", "orders:approve")
);
assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve", "orders:read"}, ScopesAllowed.Match.ANY));
assertTrue(mw.scopesAllowed(claims, new String[]{"payments:write"}, ScopesAllowed.Match.ANY));
assertFalse(mw.scopesAllowed(claims, new String[]{"unknown"}, ScopesAllowed.Match.ANY));
}
@Test
void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() {
assertEquals("abc.def.ghi", OidcMiddleware.extractBearerToken("Bearer abc.def.ghi"));
assertEquals("abc", OidcMiddleware.extractBearerToken(" bearer abc "));
assertNull(OidcMiddleware.extractBearerToken("Basic Zm9vOmJhcg=="));
assertNull(OidcMiddleware.extractBearerToken("Bearer"));
}
@Test
void bearerChallenge_containsRealmAndRfcErrors() {
OidcMiddleware mw = middleware("roles", "scope,scp");
String basic = mw.bearerChallenge();
String invalid = mw.invalidTokenChallenge();
String insufficient = mw.insufficientScopeChallenge(new String[]{"orders:read", "payments:write"});
assertTrue(basic.startsWith("Bearer realm=\""));
assertTrue(invalid.contains("error=\"invalid_token\""));
assertTrue(insufficient.contains("error=\"insufficient_scope\""));
assertTrue(insufficient.contains("scope=\"orders:read payments:write\""));
}
}
@@ -1,124 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.ext.openapi.OpenApiContributor;
import dev.relism.flash.extension.FlashContext;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OidcOpenApiInteropTest {
@Authenticated
static class AuthOnly {}
@Authenticated(optional = true)
static class AuthOptional {}
@RolesAllowed("admin")
static class OneRole {}
@RolesAllowed({"admin", "operator"})
static class MultiRole {}
@ScopesAllowed("orders:write")
static class OneScope {}
@ScopesAllowed({"orders:write", "payments:write"})
static class MultiScope {}
@RolesAllowed("admin")
@ScopesAllowed("orders:write")
static class RoleAndScope {}
@Test
void autoResponses_authOnly() throws Exception {
Map<Integer, String> responses = responses(AuthOnly.class);
assertEquals("Authentication required", responses.get(401));
assertFalse(responses.containsKey(403));
}
@Test
void autoResponses_optionalAuth_addsNothing() throws Exception {
Map<Integer, String> responses = responses(AuthOptional.class);
assertTrue(responses.isEmpty());
}
@Test
void autoResponses_oneRole_formatsSingular() throws Exception {
Map<Integer, String> responses = responses(OneRole.class);
assertEquals("Authentication required", responses.get(401));
assertEquals("\"admin\" role required", responses.get(403));
}
@Test
void autoResponses_multiRoles_formatsPlural() throws Exception {
Map<Integer, String> responses = responses(MultiRole.class);
assertEquals("Roles \"admin, operator\" are required", responses.get(403));
}
@Test
void autoResponses_oneScope_formatsSingular() throws Exception {
Map<Integer, String> responses = responses(OneScope.class);
assertEquals("\"orders:write\" scope required", responses.get(403));
}
@Test
void autoResponses_multiScopes_formatsPlural() throws Exception {
Map<Integer, String> responses = responses(MultiScope.class);
assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403));
}
@Test
void autoResponses_roleAndScope_combinesMessages() throws Exception {
Map<Integer, String> responses = responses(RoleAndScope.class);
assertEquals("\"admin\" role required; \"orders:write\" scope required", responses.get(403));
}
@Test
void securityContribution_presentForAuthenticatedHandler() throws Exception {
OpenApiOperationContribution operation = contributor().operationFor(AuthOnly.class);
List<Map<String, List<String>>> security = operation.security();
assertEquals(1, security.size());
assertTrue(security.getFirst().containsKey("issuer"));
}
private static OpenApiContributor contributor() throws Exception {
Class<?> clazz = Class.forName("dev.relism.flash.ext.oidc.OidcExtension$OpenApiIntegration");
Constructor<?> ctor = clazz.getDeclaredConstructor();
ctor.setAccessible(true);
Object instance = ctor.newInstance();
Method m = clazz.getDeclaredMethod("register", FlashContext.class, OidcConfig.class, OidcProviderMetadata.class);
m.setAccessible(true);
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
ctx.complete();
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
m.invoke(instance, ctx, config, meta);
return registry.contributors().getFirst();
}
private static Map<Integer, String> responses(Class<?> cls) throws Exception {
Map<Integer, OpenApiResponseContribution> byCode = contributor().operationFor(cls).responses();
java.util.LinkedHashMap<Integer, String> out = new java.util.LinkedHashMap<>();
for (Map.Entry<Integer, OpenApiResponseContribution> e : byCode.entrySet()) {
out.put(e.getKey(), e.getValue().description());
}
return out;
}
}
@@ -1,47 +0,0 @@
package dev.relism.flash.ext.oidc;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class OidcUserScopesTest {
@Test
void scopes_readsStandardScopeString() {
OidcUser user = new OidcUser(Map.of("scope", "openid profile orders:read"));
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes());
assertTrue(user.hasScope("orders:read"));
assertFalse(user.hasScope("orders:write"));
}
@Test
void scopes_fallsBackToScpArray() {
OidcUser user = new OidcUser(Map.of("scp", List.of("orders:write", "payments:write")));
assertEquals(List.of("orders:write", "payments:write"), user.scopes());
assertTrue(user.hasScope("payments:write"));
}
@Test
void scopes_supportsCustomClaimPaths() {
OidcUser user = new OidcUser(Map.of("permissions", Map.of("scopes", List.of("a", "b"))));
assertEquals(List.of("a", "b"), user.scopes("permissions.scopes"));
assertTrue(user.hasScope("permissions.scopes", "a"));
assertFalse(user.hasScope("permissions.scopes", "x"));
}
@Test
void scopes_combinesMultipleClaimPathsInOrder() {
OidcUser user = new OidcUser(Map.of(
"scope", "openid",
"scp", List.of("profile", "orders:read")
));
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes("scope,scp"));
}
}
+108 -104
View File
@@ -1,6 +1,7 @@
# flash-ext-openapi # flash-ext-openapi
OpenAPI 3.0.3 generation + Swagger UI for Flash. OpenAPI 3.0.3 generation and Swagger UI, built from what the handlers already say about
themselves.
## What it provides ## What it provides
@@ -10,8 +11,6 @@ OpenAPI 3.0.3 generation + Swagger UI for Flash.
| `GET /openapi.yaml` | OpenAPI spec YAML | | `GET /openapi.yaml` | OpenAPI spec YAML |
| `GET /openapi/swagger` | Swagger UI | | `GET /openapi/swagger` | Swagger UI |
## Install
```java ```java
FlashApp.create(8080) FlashApp.create(8080)
.install(new JacksonExtension()) .install(new JacksonExtension())
@@ -20,135 +19,140 @@ FlashApp.create(8080)
.startAndBlock(); .startAndBlock();
``` ```
## Operation annotation ## What you get without writing anything
Every class-based route is documented, annotated or not. Read off the code:
- **path parameters**, from `/{id}` in the route
- **the request body**, from the handler's own body type (see below)
- **the response schema**, from what `handle` returns — an object, a `List<T>`, a `Map<String, T>`
- **the media types**: `@Consumes` for what it reads, `@Produces` for what it answers, which are
two different questions — taking a JSON body does not make the answer JSON
- **error responses**, in the one shape Flash answers failures with: `{"error": "...", "status": 404}`
- **security and rate limiting**, from the extensions that enforce them
Annotations add what the code cannot say: prose, extra statuses, examples. They never repeat it.
## Leaving a route out
```java
@GET("/healthz")
@Undocumented
public final class Health extends RequestHandler { ... }
```
Every route is documented, so a document never lies by omission. `@Undocumented` says a route is
not part of the API — a health check, an internal callback, something on its way out. On a base
class it leaves out every handler written against it.
## Request bodies
A handler that extends `BodyHandler``JsonHandler` and `XmlHandler`, and anything else that
reads a format — declares its body type in its signature, and that is the whole documentation:
```java
@POST("/users")
public final class CreateUser extends JsonHandler<NewUser> {
@Override protected Object handle(Request req, Response res, NewUser body) {
return users.create(body);
}
}
```
```yaml
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NewUser' }
```
The media type of the body comes from `@Consumes` on the base class, so an XML handler documents
itself as XML without a word from the route.
For a handler that reads the body by hand, or to describe it as something else, declare it:
```java
@PUT("/users")
@RequestBody(value = User.class, array = true, description = "Users to store")
public final class ReplaceUsers extends RequestHandler { ... }
```
## Operations
```java ```java
@GET("/users/{id}") @GET("/users/{id}")
@ApiOperation(summary = "Get user", description = "Returns one user", tags = {"users"}) @ApiOperation(summary = "Get user", description = "Returns one user", tags = {"users"})
@Parameter(name = "expand", in = ParameterIn.QUERY, type = SchemaType.STRING, examples = {"roles", "permissions"}) @Parameter(name = "expand", in = ParameterIn.QUERY, type = SchemaType.STRING, examples = {"roles", "permissions"})
@APIResponse(
responseCode = "200",
description = "User found",
content = @Content(contentType = ContentType.JSON, schema = UserDto.class)
)
public final class GetUser extends RequestHandler { ... } public final class GetUser extends RequestHandler { ... }
``` ```
## Response patterns `@ApiOperation` is optional: without it the route is still in the document, with no summary.
### Single object ## Responses
The success response is inferred. Declare one only to say more:
```java ```java
@APIResponse( @APIResponse(responseCode = "200", description = "User found",
responseCode = "200", content = @Content(schema = UserDto.class, example = "{\"id\":\"usr-1\"}"))
description = "User found", @APIResponse(responseCode = "409", description = "That email is taken")
content = @Content(contentType = ContentType.JSON, schema = UserDto.class) @APIResponse(responseCode = "204", description = "Deleted")
)
``` ```
### Array - `content.schema` omitted on a 2xx: the handler's return type.
- Any 4xx or 5xx without an explicit schema: Flash's error object, referenced from `components`
in JSON, which is what Flash answers a failure with whatever the route produces.
- `content.array = true` wraps whichever schema was chosen.
- No schema and nothing to infer one from: a response with no body, which is what a 204 is.
The media type of every answer is the handler's `@Produces`, JSON when nothing says otherwise. It
is not on `@Content`: one handler answers in one format, and a status code does not change that.
**A response several operations share is written once.** Identical answers — the 401 of every
guarded route, the 429 of every limited one — become `components.responses` entries referenced by
`$ref`, instead of being repeated on every path.
## DTO schemas
```java ```java
@APIResponse( @Schema(name = "User", title = "User DTO", description = "Public user")
responseCode = "200", public record UserDto(
description = "Users listed", @SchemaProperty(title = "ID", example = "USR-100") String id,
content = @Content(contentType = ContentType.JSON, schema = UserDto.class, array = true) @SchemaProperty(hidden = true) String internalDebug) {}
)
``` ```
### No content Each type is described once under `components.schemas` and referenced everywhere it appears.
Field-level exclusion: `@Schema(hidden = true)`, `@SchemaProperty(hidden = true)`, `@JsonIgnore`,
```java `@JsonIgnoreProperties`, `transient`, `static`. `jakarta.validation` constraints (`@NotNull`,
@APIResponse( `@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`) become the schema's own
responseCode = "204", bounds and required fields, so a rule is written once and documented for free.
description = "Deleted",
content = @Content(contentType = ContentType.NONE)
)
```
### Inferred from handler return type
```java
@APIResponse(
responseCode = "200",
content = @Content
)
```
If `content.schema` is omitted, schema is inferred from the handler `handle(...)` return type.
Explicit `content.schema` always wins over inference.
Inference defaults:
- `UserDto` -> object schema for `UserDto`
- `List<UserDto>` / `Set<UserDto>` / `UserDto[]` -> `array` with `items: UserDto`
- `Map<String, UserDto>` -> `object` with `additionalProperties: UserDto`
## DTO schema metadata
```java
@Schema(name = "User", title = "User DTO", description = "Public user", deprecated = false)
public class UserDto {
@SchemaProperty(title = "ID", required = true, example = "USR-100", enumeration = {"USR-100", "USR-101"})
public String id;
@SchemaProperty(hidden = true)
public String internalDebug;
}
```
Supported field-level exclusion:
- `@Schema(hidden = true)` / `@SchemaProperty(hidden = true)`
- `@JsonIgnore`
- `@JsonIgnoreProperties(...)`
- `transient` / `static`
## Contributor API ## Contributor API
OpenAPI is extension-agnostic. Other extensions contribute with `OpenApiContributor` via OpenAPI is extension-agnostic. Other extensions contribute through `OpenApiContributor`, held in
`OpenApiContributorRegistry`. `OpenApiContributorRegistry`:
Supported contribution surfaces: - `components` fragments (merged last-wins)
- `components` fragments (merged with last-wins)
- operation `security` requirements (additive) - operation `security` requirements (additive)
- operation `responses` and response `headers` (additive) - operation `responses` and response `headers` (additive)
Merge policy: Manual `@APIResponse` description always wins over a contributor's for the same status.
- contributor collisions use **last-wins** ### Security interop
- manual `@APIResponse` description always wins over contributors for the same status
## OIDC interop 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.
When `flash-ext-oidc` is installed, OpenAPI integrates automatically: ### Limiter interop
- security scheme under `components.securitySchemes` When `flash-ext-limiter` is installed, handlers with `@Limit` document `X-RateLimit-Limit`,
- per-operation `security` `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and a `429` with `Retry-After`.
- auto responses (class-based handlers):
- `401 Authentication required`
- `403` role/scope required messages when applicable
Manual `@APIResponse` for the same status code always wins.
## Limiter interop
When `flash-ext-limiter` is installed, handlers with `@Limit` automatically get response
headers documented in OpenAPI:
- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`
- `X-RateLimit-Reset`
- `Retry-After` on `429`
If `429` is missing, it is auto-added as `Too Many Requests`.
## Notes ## Notes
- Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`. - Operations come from the final boot-time routes, so documented paths match runtime paths,
- Documented paths always match runtime paths (including scope namespaces/prefixes/rewrites). namespaces, prefixes and rewrites included.
- Route path params are auto-discovered from `/{id}`. - Lambda routes are not documented: there is no class to read.
- Parameter annotations are mainly for query/header/cookie enrichment. - Responses are sorted by status code; the document is rebuilt only when a route is added.
- Output responses are sorted by numeric status code.
@@ -1,19 +1,23 @@
package dev.relism.flash.ext.openapi; package dev.relism.flash.ext.openapi;
import dev.relism.flash.http.ContentType;
import java.lang.annotation.ElementType; import java.lang.annotation.ElementType;
import java.lang.annotation.Retention; import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; import java.lang.annotation.Target;
/** /**
* OpenAPI response content descriptor. * What a response carries: the schema, and an example of it.
*
* <p>The media type is not here it is the handler's, declared with
* {@link dev.relism.flash.routing.Produces @Produces} and JSON when nothing says otherwise. A
* response with no schema, and no return type to infer one from, is documented without a body.
*/ */
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD}) @Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
public @interface Content { public @interface Content {
ContentType contentType() default ContentType.JSON;
Class<?> schema() default Void.class; Class<?> schema() default Void.class;
boolean array() default false; boolean array() default false;
/** One example body, shown beside the schema. */
String example() default "";
} }
@@ -1,47 +1,51 @@
package dev.relism.flash.ext.openapi; package dev.relism.flash.ext.openapi;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonProperty.Access;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.ContentType; import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.HttpStatus; import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.models.Request; import dev.relism.flash.models.BodyHandler;
import dev.relism.flash.models.Response; import dev.relism.flash.routing.Consumes;
import dev.relism.flash.routing.Produces;
import dev.relism.flash.routing.Route; import dev.relism.flash.routing.Route;
import java.lang.annotation.Annotation; import java.lang.annotation.Annotation;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.GenericArrayType;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type; import java.lang.reflect.Type;
import java.time.Instant; import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID;
import java.nio.charset.StandardCharsets;
/** /**
* OpenAPI document assembler. * Assembles the OpenAPI document from what the handlers already say about themselves.
*
* <p>A route is documented whether or not it carries annotations: its path parameters come from
* the path, its request body from the handler's own body type, its response schema from what
* {@code handle} returns, and its error bodies from the one shape Flash answers failures with.
* Annotations add what code cannot say a summary, an example, a second status and never have
* to repeat what it can.
*
* <p>A response that several operations share is written once under {@code components} and
* referenced, so the security and rate-limiting answers appear once rather than on every path.
*/ */
public final class OpenApiBuilder { public final class OpenApiBuilder {
private static final String OPENAPI_VERSION = "3.0.3"; private static final String OPENAPI_VERSION = "3.0.3";
private static final String ERROR_SCHEMA = "Error";
private static final String ERROR_REF = "#/components/schemas/" + ERROR_SCHEMA;
private static final String RESPONSE_REF = "#/components/responses/";
/** What {@code AbstractRouter} answers every failure with: one object, everywhere. */
private static final Map<String, Object> ERROR_SHAPE = Map.of(
"type", "object",
"properties", Map.of("error", Map.of("type", "string"), "status", Map.of("type", "integer")),
"required", List.of("error", "status"));
private String title = "API"; private String title = "API";
private String version = "1.0.0"; private String version = "1.0.0";
@@ -49,8 +53,9 @@ public final class OpenApiBuilder {
private final Map<String, Map<String, Object>> paths = new LinkedHashMap<>(); private final Map<String, Map<String, Object>> paths = new LinkedHashMap<>();
private final Map<String, Map<String, Class<?>>> operationHandlers = new LinkedHashMap<>(); private final Map<String, Map<String, Class<?>>> operationHandlers = new LinkedHashMap<>();
private final SchemaRegistry schemas = new SchemaRegistry(); private final Schemas schemas = new Schemas();
private OpenApiContributorRegistry contributorRegistry; private OpenApiContributorRegistry contributorRegistry;
private boolean errorsDocumented;
private int revision; private int revision;
private int builtRevision = -1; private int builtRevision = -1;
private Map<String, Object> cachedSpec; private Map<String, Object> cachedSpec;
@@ -60,273 +65,384 @@ public final class OpenApiBuilder {
public OpenApiBuilder description(String description) { this.description = description; return this; } public OpenApiBuilder description(String description) { this.description = description; return this; }
void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; } void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; }
/**
* Documents one route. {@code op} is optional: a route without it is still an operation.
* A handler marked {@link Undocumented} is left out entirely.
*/
public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) { public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) {
if (handlerClass.isAnnotationPresent(Undocumented.class)) return;
String path = normalizePath(route.path()); String path = normalizePath(route.path());
String method = route.method().name().toLowerCase(Locale.ROOT); String method = route.method().name().toLowerCase(Locale.ROOT);
Map<String, Object> operation = new LinkedHashMap<>(); Map<String, Object> operation = new LinkedHashMap<>();
if (op != null) {
if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId()); if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId());
if (!op.summary().isEmpty()) operation.put("summary", op.summary()); if (!op.summary().isEmpty()) operation.put("summary", op.summary());
if (!op.description().isEmpty()) operation.put("description", op.description()); if (!op.description().isEmpty()) operation.put("description", op.description());
if (op.tags().length > 0) operation.put("tags", Arrays.asList(op.tags())); if (op.tags().length > 0) operation.put("tags", List.of(op.tags()));
if (op.deprecated()) operation.put("deprecated", true); if (op.deprecated()) operation.put("deprecated", true);
}
buildParameters(operation, handlerClass, route); parameters(operation, handlerClass, route);
buildResponses(operation, handlerClass); requestBody(operation, handlerClass);
responses(operation, handlerClass);
paths.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, operation); paths.computeIfAbsent(path, p -> new LinkedHashMap<>()).put(method, operation);
operationHandlers.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, handlerClass); operationHandlers.computeIfAbsent(path, p -> new LinkedHashMap<>()).put(method, handlerClass);
revision++; revision++;
} }
public Map<String, Object> build() { public Map<String, Object> build() {
int r = revision; int current = revision;
Map<String, Object> cached = cachedSpec; Map<String, Object> cached = cachedSpec;
if (cached != null && builtRevision == r) return cached; if (cached != null && builtRevision == current) return cached;
List<OpenApiContributor> contributors = contributors();
Map<String, Object> renderedPaths = new LinkedHashMap<>();
for (var path : paths.entrySet()) {
Map<String, Class<?>> handlers = operationHandlers.getOrDefault(path.getKey(), Map.of());
Map<String, Object> pathItem = new LinkedHashMap<>();
for (var method : path.getValue().entrySet()) {
@SuppressWarnings("unchecked")
Map<String, Object> declared = (Map<String, Object>) method.getValue();
Map<String, Object> operation = new LinkedHashMap<>(declared);
Class<?> handler = handlers.get(method.getKey());
if (handler != null && !contributors.isEmpty()) applyContributorSecurity(operation, handler, contributors);
pathItem.put(method.getKey(), operation);
}
renderedPaths.put(path.getKey(), pathItem);
}
Map<String, Object> sharedResponses = hoistSharedResponses(renderedPaths);
Map<String, Object> info = new LinkedHashMap<>(); Map<String, Object> info = new LinkedHashMap<>();
info.put("title", title); info.put("title", title);
info.put("version", version); info.put("version", version);
if (!description.isEmpty()) info.put("description", description); if (!description.isEmpty()) info.put("description", description);
List<OpenApiContributor> contributors = contributorRegistry != null
? contributorRegistry.contributors() : List.of();
Map<String, Object> renderedPaths = new LinkedHashMap<>();
for (var pathEntry : paths.entrySet()) {
Map<String, Object> renderedPathItem = new LinkedHashMap<>();
Map<String, Class<?>> handlers = operationHandlers.getOrDefault(pathEntry.getKey(), Map.of());
for (var methodEntry : pathEntry.getValue().entrySet()) {
@SuppressWarnings("unchecked")
Map<String, Object> original = (Map<String, Object>) methodEntry.getValue();
Map<String, Object> op = new LinkedHashMap<>(original);
Class<?> handler = handlers.get(methodEntry.getKey());
if (handler != null && !contributors.isEmpty()) {
applyContributorOperation(op, handler, contributors);
}
renderedPathItem.put(methodEntry.getKey(), op);
}
renderedPaths.put(pathEntry.getKey(), renderedPathItem);
}
Map<String, Object> spec = new LinkedHashMap<>(); Map<String, Object> spec = new LinkedHashMap<>();
spec.put("openapi", OPENAPI_VERSION); spec.put("openapi", OPENAPI_VERSION);
spec.put("info", info); spec.put("info", info);
spec.put("paths", renderedPaths); spec.put("paths", renderedPaths);
Map<String, Object> components = new LinkedHashMap<>(); Map<String, Object> components = new LinkedHashMap<>();
Map<String, Object> renderedSchemas = schemas.render(); Map<String, Object> renderedSchemas = new LinkedHashMap<>(schemas.render());
if (errorsDocumented) renderedSchemas.put(ERROR_SCHEMA, ERROR_SHAPE);
if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas); if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas);
if (!contributors.isEmpty()) applyContributorComponents(components, contributors); if (!sharedResponses.isEmpty()) components.put("responses", sharedResponses);
for (OpenApiContributor contributor : contributors) {
Map<String, Object> contributed = contributor.componentContributions();
if (contributed != null && !contributed.isEmpty()) deepMergeLastWins(components, contributed);
}
if (!components.isEmpty()) spec.put("components", components); if (!components.isEmpty()) spec.put("components", components);
cachedSpec = spec; cachedSpec = spec;
builtRevision = r; builtRevision = current;
return spec; return spec;
} }
private void buildParameters(Map<String, Object> op, Class<?> cls, Route route) { // Parameters
List<Map<String, Object>> params = new ArrayList<>();
private void parameters(Map<String, Object> operation, Class<?> handlerClass, Route route) {
List<Map<String, Object>> parameters = new ArrayList<>();
String path = route.path(); String path = route.path();
int i = 0; for (int open = path.indexOf('{'); open >= 0; open = path.indexOf('{', open + 1)) {
while (i < path.length()) {
int open = path.indexOf('{', i);
if (open < 0) break;
int close = path.indexOf('}', open); int close = path.indexOf('}', open);
if (close < 0) break; if (close < 0) break;
String name = path.substring(open + 1, close); parameters.add(new LinkedHashMap<>(Map.of(
params.add(new LinkedHashMap<>(Map.of( "name", path.substring(open + 1, close),
"name", name,
"in", "path", "in", "path",
"required", true, "required", true,
"schema", Map.of("type", "string") "schema", Map.of("type", "string"))));
))); open = close;
i = close + 1;
} }
for (Parameter ann : cls.getAnnotationsByType(Parameter.class)) { for (Parameter declared : handlerClass.getAnnotationsByType(Parameter.class)) {
Map<String, Object> p = new LinkedHashMap<>(); Map<String, Object> parameter = new LinkedHashMap<>();
p.put("name", ann.name()); parameter.put("name", declared.name());
p.put("in", ann.in().wireValue()); parameter.put("in", declared.in().wireValue());
p.put("required", ann.required()); parameter.put("required", declared.required());
if (!ann.description().isEmpty()) p.put("description", ann.description()); if (!declared.description().isEmpty()) parameter.put("description", declared.description());
if (!ann.style().isEmpty()) p.put("style", ann.style()); if (!declared.style().isEmpty()) parameter.put("style", declared.style());
if (ann.explode()) p.put("explode", true); if (declared.explode()) parameter.put("explode", true);
if (ann.allowEmptyValue()) p.put("allowEmptyValue", true); if (declared.allowEmptyValue()) parameter.put("allowEmptyValue", true);
Map<String, Object> schema = new LinkedHashMap<>(); Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", ann.type().wireValue()); schema.put("type", declared.type().wireValue());
if (!ann.example().isEmpty()) schema.put("example", ann.example()); if (!declared.example().isEmpty()) schema.put("example", declared.example());
p.put("schema", schema); parameter.put("schema", schema);
if (ann.examples().length > 0) p.put("examples", toExamples(ann.examples())); if (declared.examples().length > 0) parameter.put("examples", examples(declared.examples()));
params.add(p); parameters.add(parameter);
} }
if (!params.isEmpty()) op.put("parameters", params); if (!parameters.isEmpty()) operation.put("parameters", parameters);
} }
private void buildResponses(Map<String, Object> op, Class<?> cls) { private static Map<String, Object> examples(String[] values) {
APIResponse[] anns = cls.getAnnotationsByType(APIResponse.class); Map<String, Object> examples = new LinkedHashMap<>();
Map<Integer, Map<String, Object>> responseByCode = new LinkedHashMap<>(); for (int i = 0; i < values.length; i++) examples.put("example" + (i + 1), Map.of("value", values[i]));
return examples;
for (APIResponse ann : anns) {
int code = parseStatus(ann.responseCode());
responseByCode.put(code, buildAnnotatedResponse(code, ann, cls));
} }
if (responseByCode.isEmpty()) { // Request body
responseByCode.put(200, Map.of("description", "OK"));
/** From {@link RequestBody}, or from the body type the handler declares in its own signature. */
private void requestBody(Map<String, Object> operation, Class<?> handlerClass) {
RequestBody declared = handlerClass.getAnnotation(RequestBody.class);
Class<?> type = declared != null ? declared.value() : BodyHandler.bodyTypeOf(handlerClass);
if (type == null || type == Void.class || type == Object.class) return;
Consumes consumes = handlerClass.getAnnotation(Consumes.class);
ContentType contentType = declared != null ? declared.contentType()
: consumes != null ? consumes.value() : ContentType.JSON;
Map<String, Object> schema = schemas.referenceFor(type);
if (declared != null && declared.array()) schema = arrayOf(schema);
Map<String, Object> body = new LinkedHashMap<>();
if (declared != null && !declared.description().isEmpty()) body.put("description", declared.description());
body.put("required", declared == null || declared.required());
body.put("content", Map.of(mediaTypeOf(contentType), Map.of("schema", schema)));
operation.put("requestBody", body);
} }
applyContributorResponses(responseByCode, cls); // Responses
private void responses(Map<String, Object> operation, Class<?> handlerClass) {
APIResponse[] declared = handlerClass.getAnnotationsByType(APIResponse.class);
Map<Integer, Map<String, Object>> byStatus = new LinkedHashMap<>();
Set<Integer> declaredStatuses = new HashSet<>();
for (APIResponse response : declared) {
int status = parseStatus(response.responseCode());
declaredStatuses.add(status);
byStatus.put(status, response(status, response, handlerClass));
}
if (byStatus.isEmpty()) byStatus.put(200, inferredResponse(handlerClass));
applyContributorResponses(byStatus, handlerClass, declaredStatuses);
Map<String, Object> responses = new LinkedHashMap<>(); Map<String, Object> responses = new LinkedHashMap<>();
responseByCode.entrySet().stream() byStatus.entrySet().stream()
.sorted(Map.Entry.comparingByKey(Comparator.naturalOrder())) .sorted(Map.Entry.comparingByKey(Comparator.naturalOrder()))
.forEach(e -> responses.put(String.valueOf(e.getKey()), e.getValue())); .forEach(entry -> responses.put(String.valueOf(entry.getKey()), entry.getValue()));
op.put("responses", responses); operation.put("responses", responses);
} }
private Map<String, Object> response(int status, APIResponse declared, Class<?> handlerClass) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("description", declared.description().isEmpty() ? reasonFor(status) : declared.description());
Content content = declared.content();
Map<String, Object> schema = schemaFor(content, status, handlerClass);
if (schema == null) return response; // nothing to describe: a response with no body
Map<String, Object> media = new LinkedHashMap<>();
media.put("schema", schema);
if (!content.example().isEmpty()) media.put("example", content.example());
response.put("content", Map.of(mediaTypeOf(producedBy(handlerClass)), media));
return response;
}
/** Explicit first, then what the handler returns for a success, and the error shape for a failure. */
private Map<String, Object> schemaFor(Content content, int status, Class<?> handlerClass) {
if (content.schema() != Void.class) {
Map<String, Object> schema = schemas.referenceFor(content.schema());
return content.array() ? arrayOf(schema) : schema;
}
if (status >= 400) return errorSchema();
Map<String, Object> inferred = returnSchema(handlerClass);
if (inferred == null) return null;
return content.array() && !"array".equals(inferred.get("type")) ? arrayOf(inferred) : inferred;
}
/** A route that documents nothing still answers something: describe what it returns. */
private Map<String, Object> inferredResponse(Class<?> handlerClass) {
Map<String, Object> response = new LinkedHashMap<>(Map.of("description", reasonFor(200)));
Map<String, Object> schema = returnSchema(handlerClass);
if (schema != null) {
response.put("content", Map.of(mediaTypeOf(producedBy(handlerClass)), Map.of("schema", schema)));
}
return response;
}
/**
* What this handler answers in. {@code @Consumes} says what it reads, which is a different
* question: taking a JSON body does not make the answer JSON.
*/
private static ContentType producedBy(Class<?> handlerClass) {
Produces produces = handlerClass.getAnnotation(Produces.class);
return produces == null ? ContentType.JSON : produces.value();
}
/** The schema of whatever {@code handle} gives back, or null when it says nothing useful. */
private Map<String, Object> returnSchema(Class<?> handlerClass) {
Type returned = returnTypeOf(handlerClass);
Class<?> raw = Schemas.rawType(returned);
if (raw == null || raw == Object.class || raw == Void.class || raw == void.class) return null;
if (raw.getName().equals("dev.relism.flash.models.Response")) return null;
Map<String, Object> schema = schemas.schemaForType(returned);
return schema == null || schema.isEmpty() ? null : schema;
}
/**
* The {@code handle} a handler writes itself, not the one its base class fixes: a handler that
* takes a body implements the three-argument one, and that is where its return type is.
*/
private static Type returnTypeOf(Class<?> handlerClass) {
for (Class<?> current = handlerClass; current != null && current != Object.class; current = current.getSuperclass()) {
Method found = null;
for (Method method : current.getDeclaredMethods()) {
if (!method.getName().equals("handle") || method.isBridge() || method.isSynthetic()) continue;
if (found == null || method.getParameterCount() > found.getParameterCount()) found = method;
}
if (found != null) return found.getGenericReturnType();
}
return null;
}
private Map<String, Object> errorSchema() {
errorsDocumented = true;
return Map.of("$ref", ERROR_REF);
}
// Contributors
private List<OpenApiContributor> contributors() { private List<OpenApiContributor> contributors() {
return contributorRegistry != null ? contributorRegistry.contributors() : List.of(); return contributorRegistry != null ? contributorRegistry.contributors() : List.of();
} }
private void applyContributorResponses(Map<Integer, Map<String, Object>> responseByCode, Class<?> handlerClass) { private void applyContributorResponses(Map<Integer, Map<String, Object>> byStatus, Class<?> handlerClass,
APIResponse[] manual = handlerClass.getAnnotationsByType(APIResponse.class); Set<Integer> declaredStatuses) {
Set<Integer> manualStatusCodes = new HashSet<>();
for (APIResponse ann : manual) {
manualStatusCodes.add(parseStatus(ann.responseCode()));
}
for (OpenApiContributor contributor : contributors()) { for (OpenApiContributor contributor : contributors()) {
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass); OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
if (contribution == null) continue; if (contribution == null) continue;
for (Map.Entry<Integer, OpenApiResponseContribution> entry : contribution.responses().entrySet()) { for (var contributed : contribution.responses().entrySet()) {
int status = entry.getKey(); if (contributed.getValue() == null) continue;
OpenApiResponseContribution responseContribution = entry.getValue(); int status = contributed.getKey();
if (responseContribution == null) continue; Map<String, Object> response = byStatus.computeIfAbsent(status, s -> new LinkedHashMap<>());
merge(response, contributed.getValue(), status, declaredStatuses.contains(status));
Map<String, Object> response = responseByCode.computeIfAbsent(status, __ -> new LinkedHashMap<>());
mergeContributorResponse(response, responseContribution, status, manualStatusCodes);
} }
OpenApiResponseContribution allResponses = contribution.allResponses(); OpenApiResponseContribution everywhere = contribution.allResponses();
if (allResponses != null) { if (everywhere == null) continue;
for (Map.Entry<Integer, Map<String, Object>> entry : responseByCode.entrySet()) { for (var response : byStatus.entrySet()) {
mergeContributorResponse(entry.getValue(), allResponses, entry.getKey(), manualStatusCodes); merge(response.getValue(), everywhere, response.getKey(), declaredStatuses.contains(response.getKey()));
}
} }
} }
} }
private static void mergeContributorResponse(Map<String, Object> response, private void merge(Map<String, Object> response, OpenApiResponseContribution contributed, int status, boolean declared) {
OpenApiResponseContribution contribution, String description = contributed.description();
int status, if (!declared && description != null && !description.isBlank()) response.put("description", description);
Set<Integer> manualStatusCodes) {
String desc = contribution.description();
if (!manualStatusCodes.contains(status) && desc != null && !desc.isBlank()) {
response.put("description", desc);
}
Map<String, Map<String, Object>> headerContributions = contribution.headers(); Map<String, Map<String, Object>> headers = contributed.headers();
if (!headerContributions.isEmpty()) { if (!headers.isEmpty()) {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> headers = (Map<String, Object>) response.computeIfAbsent("headers", __ -> new LinkedHashMap<>()); Map<String, Object> target = (Map<String, Object>) response.computeIfAbsent("headers", h -> new LinkedHashMap<>());
for (Map.Entry<String, Map<String, Object>> h : headerContributions.entrySet()) { headers.forEach((name, header) -> target.put(name, new LinkedHashMap<>(header)));
headers.put(h.getKey(), new LinkedHashMap<>(h.getValue()));
} }
if (!response.containsKey("description")) response.put("description", reasonFor(status));
// A status a contributor added is a failure Flash answers in its own shape.
if (status >= 400 && !response.containsKey("content")) {
response.put("content", Map.of(mediaTypeOf(ContentType.JSON), Map.of("schema", errorSchema())));
} // Flash answers a failure in JSON whatever the route produces
} }
if (!response.containsKey("description")) { private void applyContributorSecurity(Map<String, Object> operation, Class<?> handlerClass,
response.put("description", defaultDescription(status)); List<OpenApiContributor> contributors) {
}
}
private static void applyContributorComponents(Map<String, Object> components, List<OpenApiContributor> contributors) {
for (OpenApiContributor contributor : contributors) { for (OpenApiContributor contributor : contributors) {
Map<String, Object> c = contributor.componentContributions(); OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
if (c == null || c.isEmpty()) continue; if (contribution == null || contribution.security().isEmpty()) continue;
deepMergeLastWins(components, c); @SuppressWarnings("unchecked")
List<Map<String, List<String>>> security =
(List<Map<String, List<String>>>) operation.computeIfAbsent("security", s -> new ArrayList<>());
security.addAll(contribution.security());
} }
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private static void deepMergeLastWins(Map<String, Object> target, Map<String, Object> incoming) { private static void deepMergeLastWins(Map<String, Object> target, Map<String, Object> incoming) {
for (Map.Entry<String, Object> e : incoming.entrySet()) { incoming.forEach((key, value) -> {
Object existing = target.get(e.getKey()); Object existing = target.get(key);
Object value = e.getValue(); if (existing instanceof Map<?, ?> from && value instanceof Map<?, ?> to) {
if (existing instanceof Map<?, ?> em && value instanceof Map<?, ?> vm) { Map<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) from);
Map<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) em); deepMergeLastWins(merged, (Map<String, Object>) to);
deepMergeLastWins(merged, (Map<String, Object>) vm); target.put(key, merged);
target.put(e.getKey(), merged);
} else { } else {
target.put(e.getKey(), value); target.put(key, value);
}
} }
});
} }
private void applyContributorOperation(Map<String, Object> op, Class<?> handlerClass, List<OpenApiContributor> contributors) { // Shared responses
for (OpenApiContributor contributor : contributors) {
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
if (contribution == null || contribution.isEmpty()) continue;
if (!contribution.security().isEmpty()) { /**
* The answer a status is usually given is written once under {@code components.responses} and
* referenced. Authentication and rate limiting say the same thing on every route they guard;
* the document should say it once, under that status's own name. A route that answers the same
* status differently keeps its own wording, inline, rather than pushing a second name into the
* components.
*/
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
List<Map<String, List<String>>> security = (List<Map<String, List<String>>>) op private Map<String, Object> hoistSharedResponses(Map<String, Object> renderedPaths) {
.computeIfAbsent("security", __ -> new ArrayList<>()); Map<String, Map<Object, Integer>> seen = new LinkedHashMap<>();
security.addAll(contribution.security()); for (Object pathItem : renderedPaths.values()) {
for (Object operation : ((Map<String, Object>) pathItem).values()) {
Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses");
responses.forEach((status, response) ->
seen.computeIfAbsent(status, s -> new LinkedHashMap<>()).merge(response, 1, Integer::sum));
}
}
Map<String, Object> shared = new LinkedHashMap<>();
Map<String, Object> hoisted = new LinkedHashMap<>(); // status to the one body that is shared
seen.forEach((status, bodies) -> {
Map.Entry<Object, Integer> commonest = bodies.entrySet().stream()
.max(Map.Entry.comparingByValue()).orElseThrow();
if (commonest.getValue() < 2) return;
String name = reasonFor(Integer.parseInt(status)).replace(" ", "");
if (name.isEmpty() || shared.containsKey(name)) name = "Status" + status;
shared.put(name, commonest.getKey());
hoisted.put(status, name);
});
for (Object pathItem : renderedPaths.values()) {
for (Object operation : ((Map<String, Object>) pathItem).values()) {
Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses");
for (var response : responses.entrySet()) {
String name = (String) hoisted.get(response.getKey());
if (name != null && shared.get(name).equals(response.getValue())) {
response.setValue(Map.of("$ref", RESPONSE_REF + name));
} }
} }
} }
}
return shared;
}
private Map<String, Object> buildAnnotatedResponse(int code, APIResponse ann, Class<?> handlerClass) { // Odds and ends
Map<String, Object> out = new LinkedHashMap<>();
out.put("description", ann.description().isEmpty() ? defaultDescription(code) : ann.description());
Content content = ann.content(); private static Map<String, Object> arrayOf(Map<String, Object> items) {
if (content.contentType() == ContentType.NONE) return out; return Map.of("type", "array", "items", items);
Map<String, Object> schema = resolveResponseSchema(content, handlerClass);
if (schema == null || schema.isEmpty()) return out;
out.put("content", Map.of(mediaTypeOf(content.contentType()), Map.of("schema", schema)));
return out;
} }
private static int parseStatus(String code) { private static int parseStatus(String code) {
try { try {
return Integer.parseInt(code.trim()); return Integer.parseInt(code.trim());
} catch (Exception e) { } catch (NumberFormatException e) {
throw new IllegalStateException("Invalid APIResponse.responseCode: " + code); throw new IllegalStateException("Invalid APIResponse.responseCode: " + code);
} }
} }
private Map<String, Object> resolveResponseSchema(Content content, Class<?> handlerClass) { private static String reasonFor(int status) {
if (content.schema() != Void.class) { String reason = HttpStatus.reasonForCode(status);
Map<String, Object> base = schemas.referenceFor(content.schema()); return reason == null ? "" : reason;
return content.array() ? asArraySchema(base) : base;
}
try {
Method handle = handlerClass.getMethod("handle", Request.class, Response.class);
Type ret = handle.getGenericReturnType();
Class<?> raw = rawType(ret);
if (raw == null || raw == Object.class || raw == Response.class || raw == Void.class || raw == void.class)
return null;
Map<String, Object> inferred = schemas.schemaForType(ret);
if (inferred == null || inferred.isEmpty()) return null;
if (content.array() && !"array".equals(inferred.get("type"))) return asArraySchema(inferred);
return inferred;
} catch (NoSuchMethodException e) {
return null;
}
}
private static Map<String, Object> asArraySchema(Map<String, Object> itemSchema) {
return Map.of("type", "array", "items", itemSchema);
} }
private static String mediaTypeOf(ContentType type) { private static String mediaTypeOf(ContentType type) {
@@ -334,245 +450,21 @@ public final class OpenApiBuilder {
return bytes.length == 0 ? "application/octet-stream" : new String(bytes, StandardCharsets.UTF_8); return bytes.length == 0 ? "application/octet-stream" : new String(bytes, StandardCharsets.UTF_8);
} }
private static Map<String, Object> toExamples(String[] examples) {
Map<String, Object> out = new LinkedHashMap<>();
for (int i = 0; i < examples.length; i++) {
out.put("ex" + (i + 1), Map.of("value", examples[i]));
}
return out;
}
private static String normalizePath(String path) { private static String normalizePath(String path) {
String normalized = path.startsWith("/") ? path : "/" + path; String normalized = path.startsWith("/") ? path : "/" + path;
while (normalized.startsWith("//")) { while (normalized.startsWith("//")) normalized = normalized.substring(1);
normalized = normalized.substring(1);
}
return normalized; return normalized;
} }
private static String defaultDescription(int status) { /** The route a handler class declares, through {@code @Route} or any shorthand carrying it. */
String reason = HttpStatus.reasonForCode(status);
return reason == null ? "" : reason;
}
private static Class<?> rawType(Type type) {
if (type instanceof Class<?> c) return c;
if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) return c;
if (type instanceof GenericArrayType a) {
Class<?> component = rawType(a.getGenericComponentType());
return component == null ? null : Array.newInstance(component, 0).getClass();
}
return null;
}
/** Resolved once: jakarta.validation is an optional dependency of this module. */
private static final boolean CONSTRAINTS_PRESENT = ConstraintHints.available();
private static final class SchemaRegistry {
private static final Set<Class<?>> SIMPLE = Set.of(
String.class, CharSequence.class,
Boolean.class, Byte.class, Short.class, Integer.class, Long.class, Float.class, Double.class,
boolean.class, byte.class, short.class, int.class, long.class, float.class, double.class,
UUID.class, LocalDate.class, LocalDateTime.class, OffsetDateTime.class, Instant.class
);
private final Map<Class<?>, String> names = new LinkedHashMap<>();
private final Map<String, Map<String, Object>> docs = new LinkedHashMap<>();
private final Set<Class<?>> resolving = new HashSet<>();
Map<String, Object> referenceFor(Class<?> type) {
return schemaFor(type);
}
Map<String, Object> schemaForType(Type type) {
return schemaFor(type);
}
Map<String, Object> render() {
Map<String, Object> out = new LinkedHashMap<>();
for (var e : docs.entrySet()) out.put(e.getKey(), e.getValue());
return out;
}
private Map<String, Object> schemaFor(Type type) {
if (type instanceof ParameterizedType p) {
Class<?> raw = rawType(p);
if (raw != null && Collection.class.isAssignableFrom(raw)) {
Type item = p.getActualTypeArguments()[0];
return Map.of("type", "array", "items", schemaFor(item));
}
if (raw != null && Map.class.isAssignableFrom(raw)) {
Type value = p.getActualTypeArguments().length > 1 ? p.getActualTypeArguments()[1] : Object.class;
return Map.of("type", "object", "additionalProperties", schemaFor(value));
}
if (raw != null) return schemaFor(raw);
}
Class<?> cls = rawType(type);
if (cls == null || cls == Object.class) return Map.of("type", "object");
if (cls.isArray()) return Map.of("type", "array", "items", schemaFor(cls.getComponentType()));
if (Collection.class.isAssignableFrom(cls)) return Map.of("type", "array", "items", Map.of("type", "object"));
if (Map.class.isAssignableFrom(cls)) return Map.of("type", "object", "additionalProperties", Map.of("type", "object"));
Map<String, Object> simple = simpleSchema(cls);
if (simple != null) return simple;
return Map.of("$ref", "#/components/schemas/" + registerPojo(cls));
}
private String registerPojo(Class<?> cls) {
String existing = names.get(cls);
if (existing != null) return existing;
String base = schemaName(cls);
String name = base;
int i = 2;
while (docs.containsKey(name)) name = base + i++;
names.put(cls, name);
if (resolving.contains(cls)) return name;
resolving.add(cls);
docs.put(name, buildPojoSchema(cls));
resolving.remove(cls);
return name;
}
private Map<String, Object> buildPojoSchema(Class<?> cls) {
Schema typeSchema = cls.getAnnotation(Schema.class);
JsonIgnoreProperties ignoredType = cls.getAnnotation(JsonIgnoreProperties.class);
Set<String> ignored = ignoredType == null
? Set.of()
: new HashSet<>(Arrays.asList(ignoredType.value()));
Map<String, Object> out = new LinkedHashMap<>();
out.put("type", "object");
if (typeSchema != null) applySchemaHints(out, typeSchema);
Map<String, Object> properties = new LinkedHashMap<>();
List<String> required = new ArrayList<>();
for (Field f : cls.getDeclaredFields()) {
int mod = f.getModifiers();
if (Modifier.isStatic(mod) || Modifier.isTransient(mod)) continue;
if (f.isAnnotationPresent(JsonIgnore.class)) continue;
if (ignored.contains(f.getName())) continue;
String name = f.getName();
JsonProperty jp = f.getAnnotation(JsonProperty.class);
if (jp != null && !jp.value().isEmpty()) name = jp.value();
Schema ps = f.getAnnotation(Schema.class);
SchemaProperty sp = f.getAnnotation(SchemaProperty.class);
ArraySchema array = f.getAnnotation(ArraySchema.class);
if ((ps != null && ps.hidden()) || (sp != null && sp.hidden())) continue;
if (sp != null && !sp.name().isEmpty()) name = sp.name();
Map<String, Object> property = new LinkedHashMap<>(schemaFor(f.getGenericType()));
if (ps != null) applySchemaHints(property, ps);
if (sp != null) applySchemaHints(property, sp);
if (array != null) applyArrayHints(property, array);
if (jp != null) {
if (jp.access() == Access.READ_ONLY) property.put("readOnly", true);
if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true);
}
// Constraints declared for flash-ext-validation also describe the contract, so
// mirror them here rather than making callers restate every rule as @Schema.
boolean constrainedRequired = CONSTRAINTS_PRESENT && ConstraintHints.apply(f, property);
properties.put(name, property);
if (constrainedRequired
|| (ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required()))
required.add(name);
}
if (!properties.isEmpty()) out.put("properties", properties);
if (!required.isEmpty()) out.put("required", required);
return out;
}
private static void applySchemaHints(Map<String, Object> target, Schema schema) {
if (!schema.title().isEmpty()) target.put("title", schema.title());
if (!schema.description().isEmpty()) target.put("description", schema.description());
if (!schema.format().isEmpty()) target.put("format", schema.format());
if (!schema.example().isEmpty()) target.put("example", schema.example());
if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration()));
if (schema.nullable()) target.put("nullable", true);
if (schema.deprecated()) target.put("deprecated", true);
}
private static void applySchemaHints(Map<String, Object> target, SchemaProperty schema) {
if (!schema.title().isEmpty()) target.put("title", schema.title());
if (!schema.description().isEmpty()) target.put("description", schema.description());
if (!schema.format().isEmpty()) target.put("format", schema.format());
if (!schema.example().isEmpty()) target.put("example", schema.example());
if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration()));
if (schema.nullable()) target.put("nullable", true);
if (schema.deprecated()) target.put("deprecated", true);
}
private Map<String, Object> withArrayType(Map<String, Object> property, ArraySchema array) {
if ("array".equals(property.get("type"))) return property;
Type itemType = array.itemClass() != Void.class ? array.itemClass() : Object.class;
Map<String, Object> wrapped = new LinkedHashMap<>();
wrapped.put("type", "array");
wrapped.put("items", schemaFor(itemType));
return wrapped;
}
private void applyArrayHints(Map<String, Object> property, ArraySchema array) {
Map<String, Object> target = withArrayType(property, array);
if (target != property) {
property.clear();
property.putAll(target);
}
if (array.uniqueItems()) property.put("uniqueItems", true);
if (array.minItems() >= 0) property.put("minItems", array.minItems());
if (array.maxItems() >= 0) property.put("maxItems", array.maxItems());
}
private static String schemaName(Class<?> cls) {
Schema schema = cls.getAnnotation(Schema.class);
if (schema != null && !schema.name().isEmpty()) return schema.name();
return cls.getSimpleName();
}
private static Map<String, Object> simpleSchema(Class<?> cls) {
if (!SIMPLE.contains(cls) && !cls.isEnum()) return null;
if (cls == String.class || CharSequence.class.isAssignableFrom(cls)) return Map.of("type", "string");
if (cls == Boolean.class || cls == boolean.class) return Map.of("type", "boolean");
if (cls == Integer.class || cls == int.class || cls == Long.class || cls == long.class ||
cls == Short.class || cls == short.class || cls == Byte.class || cls == byte.class) {
return Map.of("type", "integer");
}
if (cls == Float.class || cls == float.class || cls == Double.class || cls == double.class) {
return Map.of("type", "number");
}
if (cls == UUID.class) return Map.of("type", "string", "format", "uuid");
if (cls == LocalDate.class) return Map.of("type", "string", "format", "date");
if (cls == LocalDateTime.class || cls == OffsetDateTime.class || cls == Instant.class)
return Map.of("type", "string", "format", "date-time");
if (cls.isEnum()) {
Object[] constants = cls.getEnumConstants();
List<String> values = new ArrayList<>(constants.length);
for (Object c : constants) values.add(String.valueOf(c));
return Map.of("type", "string", "enum", values);
}
return null;
}
}
static Route routeOf(Class<?> cls) { static Route routeOf(Class<?> cls) {
Route direct = cls.getAnnotation(Route.class); Route direct = cls.getAnnotation(Route.class);
if (direct != null) return direct; if (direct != null) return direct;
for (Annotation ann : cls.getAnnotations()) {
Route meta = ann.annotationType().getAnnotation(Route.class); for (Annotation annotation : cls.getAnnotations()) {
Route meta = annotation.annotationType().getAnnotation(Route.class);
if (meta == null) continue; if (meta == null) continue;
String path = readPathValue(ann); String path = pathOf(annotation);
if (path == null) continue; if (path == null) continue;
HttpMethod method = meta.method(); HttpMethod method = meta.method();
return new Route() { return new Route() {
@@ -584,11 +476,10 @@ public final class OpenApiBuilder {
return null; return null;
} }
private static String readPathValue(Annotation ann) { private static String pathOf(Annotation annotation) {
try { try {
Object v = ann.annotationType().getMethod("value").invoke(ann); return annotation.annotationType().getMethod("value").invoke(annotation) instanceof String path ? path : null;
return v instanceof String s ? s : null; } catch (ReflectiveOperationException absent) {
} catch (ReflectiveOperationException ignored) {
return null; return null;
} }
} }

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