58 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 000cc79cca Merge branch 'master' into feature/ext-cache/core-and-caffeine
Resolve conflicts in AGENTS.md, pom.xml and flash-extensions/pom.xml — both
sides added new module/scope/dependency entries in the same spot (ext-validation
from master, ext-cache-core/ext-cache-caffeine from this branch); union kept.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 14:28:12 +00:00
Relism bd899d52bb Merge pull request 'Feature/ext validation/request validation' (#15) from feature/ext-validation/request-validation into master
Publish Maven packages / publish (push) Successful in 2m16s
Reviewed-on: #15
2026-09-09 14:21:17 +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 24bb10175d feat(ext-cache-core): add the caching contract and a Caffeine backend
Split the way flash-ext-data and flash-ext-view are: cache-core defines
Cache, CacheManager, CacheSpec and CacheStats and talks to nothing;
cache-caffeine implements them in process.

    users = require(CacheManager.class).build("users", spec -> spec
            .maxSize(10_000).ttl(Duration.ofMinutes(10)));

    return users.get(id, repo::findById);

get(key, loader) is the only shape most code needs and the only one that is
hard to get right: the loader runs once per key across concurrent callers
rather than each racing its own. A null result stores nothing, because caching
absence is a decision rather than a default.

build(name, spec) is idempotent per name, so two handlers wanting one cache get
one cache without coordinating who creates it. Disagreeing about the spec
throws rather than resolving to whichever handler initialised first, which is a
bug that only surfaces under load.

recordStats() is opt-in — counting is two atomic increments per lookup, and a
cache nobody measures should not pay for numbers nobody reads. Unmeasured
caches return CacheStats.DISABLED rather than zeroes that look like a cold
cache.

Caffeine rather than a hand-rolled LRU: for genuinely low traffic
ConcurrentHashMap::computeIfAbsent is one line and needs no module at all, and
this exists for when that stops being true. W-TinyLFU admission, striped
counters and amortised eviction are not a weekend's work, and getting them
wrong yields a cache slower than no cache. The adapter is deliberately thin —
every method delegates, adding no wrapper, copy or locking of its own.

Caches are dropped through FlashContext.onClose, so values do not outlive the
app holding them. Invisible with one app per process; immediate under test.

flash-ext-cache-redis is designed but not built, and has docs only — no module,
no pom, no source. An empty module that builds an empty jar is dead weight in
the reactor. The docs record what changes once the cache can fail: get() must
decide whether to fall through to the loader, values need a codec,
invalidateAll needs a key prefix that becomes wire contract, and eviction stats
stop meaning anything. Those are decisions that want a real second replica to
check them against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:30:03 +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
Zakaria El OrcheandClaude Opus 5 f68e661296 feat(ext-validation): add request validation with compiled constraints
Standard jakarta.validation annotations, compiled once per type into a flat
check table. No configuration: constraints come from the annotations already on
your types, and ValidationException extends HttpException with status 422 so
the default handler renders it without this extension registering anything.

    record CreateUser(@NotBlank @Size(max = 80) String name,
                      @Email String email,
                      @Min(18) int age) {}

    CreateUser dto = validation.body(req, CreateUser.class);

Annotations only — Hibernate Validator's engine is deliberately absent. It
resolves constraints reflectively per call and pulls ~2 MB plus EL, which is
the per-request cost this module exists to avoid. jakarta.validation-api is
~90 KB of annotations.

The passing path allocates nothing. Constraints resolve at first use into an
opcode plus operands cached in a ClassValue, so there is no map lookup and no
lock. Fields are read through MethodHandles adapted to an exact signature —
(Object)Object for references, (Object)long for primitive integrals — so
invokeExact neither boxes nor builds the argument array Field.get and
Method.invoke allocate. Checks are a flat array walked by a tableswitch rather
than a class hierarchy behind a virtual call. @Size reads a length the object
already knows and @Email scans with indexOf, because Pattern.matcher allocates
a matcher and two int arrays per call. Messages are pre-rendered at compile
time. The violation list and the exception exist only once something fails.

@Pattern is the marked exception: its regex compiles once but matcher()
allocates per call.

Constraints are read from declared fields, so records and plain classes take
one code path — a constraint on a record component propagates to its backing
field.

Jakarta null semantics are exact: only @NotNull rejects null.

flash-ext-openapi now mirrors the same annotations into the generated schema —
minLength, maxLength, minItems, minimum, maximum, pattern, format: email and
required — via an optional jakarta.validation dependency detected at boot. A
type declares its rules once and both the validator and the published contract
read them. An explicit @Schema still wins; the bridge only fills keys nobody
set, and without the annotations on the classpath the bridge class is never
loaded.

flash-ext-jackson is optional too: validate(value) works without it, only
body(req, type) needs a codec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:20:33 +00:00
Zakaria El OrcheandClaude Opus 5 fe8c6ed162 fix(core): honour HttpException status in the default exception handler
HttpException carries the status the caller meant, and its own javadoc says
extensions map it to a structured response — but nothing did. Every one reached
the catch-all and came back as 500, including the 400s that RequestHelper
raises for a malformed query param and that flash-ext-jackson raises for an
unparseable body. A handler doing the documented thing produced the wrong
status.

The default handler now renders HttpException at its own status, in both dev
and prod modes, with the message JSON-escaped. Not pre-encoded like JSON_404
and JSON_500: the message is per-exception, and a path that already unwound a
stack does not need the allocation shaved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:20:33 +00:00
Zakaria El OrcheandClaude Opus 5 58bae41f7a docs(testing): document flash-testing and the limits it deliberately keeps
README covers the application handle, requests and assertions, service
replacement, multi-server wiring, scope, WebSockets, configuration and the
teardown ordering.

limits.md records the seven things the harness cannot do and what to use for
each: TLS, HTTP/2, WebSocket over HTTP/2, malformed requests, response framing,
the flash core module's dependency cycle, and scoped services. Each is a
consequence of a real constraint rather than an unfinished feature, so writing
them down stops the next person rediscovering them one at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:07:55 +00:00
Zakaria El OrcheandClaude Opus 5 7785712efe test(ext-web-bundler): migrate integration test to flash-testing
Two frontend layouts, each laid out on disk inside its own application's
configure(). The harness runs that lazily at first access, so @TempDir is
populated by then and the server for whichever test is not running never boots.

Replaces three blocks of HttpRequest.newBuilder(URI.create(...)) per test with
single-line assertions. 98 to 60 code lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:05:06 +00:00
Zakaria El OrcheandClaude Opus 5 c02459dd7c test(ext-mcp): migrate integration and security suites to flash-testing
McpExtensionIntegrationTest: 10 stateless JSON-RPC calls against one server
config, so one class-scoped server replaces a boot per test. 117 to 85 code
lines.

McpExtensionSecurityTest: the four server configurations it exercises — AUTO
without oidc, REQUIRED with a derived resource identifier, REQUIRED with an
explicit one, and REQUIRED with advertised scopes — become four named servers
sharing one FakeOidcProvider, replacing eight boots and a freePort() helper.
151 to 128 code lines. The boot-rejection test still builds its app directly:
a harness whose job is to boot an app is the wrong tool for asserting that
booting fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:05:06 +00:00
Zakaria El OrcheandClaude Opus 5 e0ad83eb2f test(core): read bound ports back from the app instead of guessing free ones
Every integration test picked a port by opening ServerSocket(0), closing it and
reusing the number, which races anything else on the machine between the close
and the rebind. FlashApp.port() reports the port the listener actually bound,
so the guess is gone: 18 freePort() helpers deleted, 40 call sites now pass
port(0) and read the result back.

Two ServerSocket(0) uses remain and are correct. ConnectionRunnerTest accepts
on its socket rather than using it to pick a number. H2LoadMeasurementTest
hands its port to an external nghttpd process, which has no equivalent of
port() to read back; that one is now commented to say so.

HttpServerTlsTest's two-listener case reads both back through ports().

HttpServerTest and HttpServerConcurrencyTest also move from @BeforeEach to
@BeforeAll — every test in them is read-only against the same routes, so 11 and
3 boots respectively become 1. HttpServerConcurrencyTest's lazy-compile test
keeps building its own app, since a freshly compiled router is the thing it
tests. HttpServerTest now runs 11 tests in 0.06s.

The http2 interop suites (curl, nghttp, grpcurl, h2spec), the load measurement
and the soak test are skipped without their external binaries or system
property, so those edits are compile-verified here and exercised in CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:05:06 +00:00
Zakaria El OrcheandClaude Opus 5 1c207cf94c feat(testing): boot lazily instead of eagerly in beforeEach
beforeEach called ensureStarted(), so every FlashTest field in a class booted
for every test whether or not that test touched it — a class holding four
servers paid for four boots per test. Booting is already lazy on first access,
so the hook was only ever forcing work forward.

Neither hook starts anything now. beforeAll still records that a static field
owns the class-scoped lifecycle, which is what keeps afterEach from tearing a
class-scoped server down after the first test.

This also lets an application read @TempDir inside configure(): JUnit populates
those during instance post-processing, before the first test body but after
extension beforeEach callbacks would have fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:05:06 +00:00
Zakaria El OrcheandClaude Opus 5 fa5a025c93 test(ext-mcp): migrate McpAuthPolicyTest to flash-testing
First migration, chosen because it is the case that drove the harness design:
a Flash app plus a FakeOidcProvider, with tokens audience-bound to the app's
own port, so the port has to be readable after boot.

Drops the racy free-port dance, the hand-rolled HttpClient plumbing and the
per-test teardown; failures now report the response body. 120 to 106 code
lines, and what remains is tool-policy assertions rather than fixture code.

The two boot-rejection tests keep building their app directly — a harness whose
job is to boot an app is the wrong tool for asserting that booting fails — but
port(0) removes freePort() from those too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 10:22:40 +00:00
Zakaria El OrcheandClaude Opus 5 424ca31b7a feat(testing): add flash-testing, a JUnit 5 harness for Flash applications
Boots a real app on an OS-assigned port for a test class or a single test and
hands back a client pointed at it:

    @RegisterExtension
    static FlashTest app = FlashTest.of(new BlogApp())
            .mock(UserService.class, new InMemoryUserService());

    app.get("/api/users").expectStatus(200).expectBodyContains("alice");

A field rather than an annotation, because annotation values are compile-time
constants and so could never express a second server wired from the first —
FlashTest.of(new BlogApp(auth.baseUri())). Startup is lazy, so reading
baseUri() boots that server on the spot and declaration order does the wiring,
with no dependence on JUnit's extension ordering. A static field boots once per
class, a non-static field once per test; that is stock JUnit field semantics
rather than an option to configure.

Runs against a real loopback port instead of dispatching in-process. An
in-process dispatcher would be a third copy of the routing/handler/exception
sequence that Http1Connection and Http2StreamDispatcher already duplicate, kept
in sync by hand, and it would let a test pass while the status line,
content-length or HPACK encoding was broken.

mock() installs overrides as the last extension, after everything the
application and its extensions declare, so a fake always wins. Any object is
accepted, so a hand-written fake and a Mockito mock are equally welcome and
this module depends on no mocking library — only flash and junit-jupiter-api.

Teardown cancels the client before stopping the server: HttpClient holds
keep-alive sockets open and ServerLifecycle.stop() spins until the last one
closes, so the default 15s drain would otherwise be paid on every test class.
shutdownNow rather than close(), which blocks until every operation completes
and would hang on a leaked WebSocket.

Lives at the top level, not under flash-extensions/, which holds things you
install() onto an app; this carries junit-jupiter-api at compile scope and
nothing installable should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 10:22:40 +00:00
Zakaria El OrcheandClaude Opus 5 4a85a27648 feat(core): expose bound ports, service override, FlashApplication and close hooks
Four small seams, each useful on its own, that together make a Flash app
testable without hand-rolled scaffolding.

- ServerHandle/ServerLifecycle/FlashApp gain port()/ports(). Listeners already
  bind in the FlashApp constructor, so port(0) resolved to a real port that
  nothing could read back; every integration test worked around this by
  opening a ServerSocket(0), closing it and reusing the number, which races
  anything else on the machine.

- FlashContext.override() replaces a binding instead of rejecting it. The
  duplicate-is-an-error rule stays everywhere else; this is the single
  deliberate exception, for swapping a service out in tests. A replacement is
  logged at INFO so misuse in production is visible.

- FlashApplication + FlashApp.apply() name an application independently of the
  port it runs on, so the same one can be booted twice. It takes FlashApp
  rather than FlashRegistrar because ws() and mount() live there. Being a
  functional interface, a lambda and a named class are the same thing.

- FlashContext.onClose() runs cleanup at stop(), children first and then in
  reverse registration order. stop() previously closed sockets and the
  executor and never touched the service graph, so a pooled DataSource was
  only ever released by JVM exit — invisible with one app per process, a leak
  per test class once a suite boots and stops many.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 10:22:23 +00:00
Zakaria El OrcheandClaude Sonnet 5 74169e40f4 feat(core): add any() route registration, warn on undocumented OpenAPI routes, log unhandled 500s
Publish Maven packages / publish (push) Failing after 3m35s
FlashRegistrar#any() registers a handler under every HTTP method for
verb-indifferent handlers (e.g. a reverse proxy). OpenApiExtension now warns
when a class-based handler has no @ApiOperation instead of silently omitting
it from the spec. AbstractRouter's default production exception handler now
logs unhandled exceptions server-side instead of only returning a JSON 500.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 11:56:51 +00:00
Zakaria El OrcheandClaude Sonnet 5 3d743b34ce fix(build): resolve fpr-core from Gitea, not the dead maven.relism.dev
Publish Maven packages / publish (push) Successful in 2m3s
maven.relism.dev is down. fpr-core's source (FastPathRouter) now publishes
to Gitea's own Maven registry via its own publish-maven.yml, same pattern
this project already uses for itself — point <repositories> there and bump
the pinned fpr-core version to the first build actually published there
(1.1.0-36216cd). Full reactor build + test suite green against the new
resolution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 11:10:50 +00:00
Relism f8e0a1d3fa Merge pull request 'fix(http2): close a streaming response body on every exit path' (#13) from hotfix/http2-response-writer-stream-leak into master
Publish Maven packages / publish (push) Successful in 2m9s
Reviewed-on: #13
2026-08-14 23:02:30 +00:00
Relism 5954cac66c Merge pull request 'fix(websocket): mask outgoing CLOSE frames in client mode' (#12) from hotfix/ws-close-frame-masking into master
Publish Maven packages / publish (push) Canceled after 7s
Reviewed-on: #12
2026-08-14 23:02:12 +00:00
Zakaria El OrcheandClaude Sonnet 5 e5bec59410 fix(http2): close a streaming response body on every exit path
Http2ResponseWriter.streamBody (a streaming response's InputStream, driven
across startFlowControlled/resume as flow-control windows allow) was never
closed anywhere -- not on clean EOF, not on a write failure, not when the
stream is abandoned (RST_STREAM from the peer, connection teardown). Same
bug Http1ResponseWriter had before cf16be0, just never given the same fix: a
handler stream that releases a held resource (a pooled backend connection,
for a reverse proxy) from close() leaks it under any real amount of stream
resets or aborted connections.

- appendData closes streamBody once `end` is reached (clean completion) and
  on any IOException from the read itself, mirroring Http1ResponseWriter's
  relayAndClose/writeChunkedAndClose reasoning.
- New Http2ResponseWriter#abort(), called from Http2Stream#cancel() --
  symmetric with that method's existing http2Body.cancel() for the inbound
  leg, now covering the outbound one too. cancel() is the single hook every
  abandoned-stream path (RST_STREAM handling and connection teardown in
  Http2Connection, plus Http2StreamDispatcher) already goes through, so this
  covers every abort case without adding a new one.

closeStreamBodyQuietly() is idempotent (nulls streamBody after closing), so
the appendData and abort() close paths can't double-close or race.

Four new Http2ResponseWriterTest cases: normal completion in one call,
completion across a resume() (multiple flow-control windows), abort() while
still streaming, and abort() as a no-op on a non-streaming response. 697/697
flash-module tests green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LLwcyHUnbApCrY33gvgoa
2026-08-14 22:51:16 +00:00
Zakaria El OrcheandClaude Sonnet 5 901da954a9 fix(websocket): mask outgoing CLOSE frames in client mode
WebSocketSession.close(int) hand-wrote a raw, always-unmasked 4-byte CLOSE
frame, bypassing writeFrame's maskOutgoing handling that sendText/send/
sendPong already go through correctly. A client-mode session (maskOutgoing
true — WS-client usage, e.g. Pathway's UpstreamWebSocketConnector relaying a
proxied client's close to a backend) therefore sent an RFC-6455-invalid
unmasked frame.

This was latent until this same HTTP/2 branch's readFrame rewrite added the
receive-side masking check RFC 6455 §5.1 requires: a strict peer now rejects
the malformed frame with WebSocketProtocolException("client frame must be
masked") before ever exposing it as a CLOSE, silently dropping the close
instead of relaying it — reproduced end to end via Pathway's
ProxyWebSocketIntegrationTest.clientCloseIsForwardedToTheBackend.

close(int) now builds its 2-byte payload and calls the same writeFrame path
every other outgoing frame uses, so masking (or not) follows maskOutgoing
automatically. Existing server-mode close_setsClosedAndWritesFrame is
unchanged (byte-for-byte identical output — no mask bit, no key). Added
close_masksWhenActingAsClient (mirrors the existing sendText coverage) and a
round-trip regression, readFrame_acceptsCloseFrameWrittenByAClientSession,
that reproduces the actual bug: a client session's close() output fed
straight into a server session's readFrame().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LLwcyHUnbApCrY33gvgoa
2026-08-14 22:46:59 +00:00
Relism 787ae610d4 Merge pull request 'fix(ext): update stale HeaderMap references to Http1HeaderMap' (#11) from hotfix/header-map-rename into master
Publish Maven packages / publish (push) Successful in 2m36s
Reviewed-on: #11
2026-08-14 18:27:13 +00:00
Zakaria El OrcheandClaude Sonnet 5 b5ff5a4c1f fix(ext): update stale HeaderMap references to Http1HeaderMap
HeaderMap was split into the HeaderView interface and Http1HeaderMap
impl in the HTTP/2 model refactor, but two extension-module tests
(flash-ext-jackson, flash-ext-view-jte) still referenced the old
class name, breaking the CI build on master after the HTTP/2 PR merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCmqb7XGb3JQvCWf589yma
2026-08-14 18:25:42 +00:00
343 changed files with 12842 additions and 7837 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-limiter/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-security-oidc/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-security-oidc/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
@@ -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/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-web-bundler/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/java" 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/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
+7 -3
View File
@@ -36,9 +36,10 @@ Format: `<type>(<scope>): <short description>`
| `chore` | Build, deps, tooling — no production code |
| `ci` | Changes to GitHub Actions workflows |
Allowed scopes: `core`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`,
`ext-mcp`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`.
Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-vite`,
`ext-mcp`, `ext-validation`, `ext-scheduler`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`,
`ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`.
Examples:
```
@@ -85,6 +86,9 @@ chore(release): 2.1.0
- Root POM: `flash-parent` — defines all dependency versions and plugin config.
- `flash` module: the core framework JAR.
- `flash-testing` module: JUnit 5 harness for testing Flash applications. Deliberately not
under `flash-extensions/` — it is not something you `install()`, and it carries
`junit-jupiter-api` at compile scope.
- `flash-extensions` POM: aggregator for all extension modules.
- Extensions live under `flash-extensions/flash-ext-*/`.
- When adding a new extension:
+131 -5
View File
@@ -8,13 +8,26 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
| Module | Description |
|---|---|
| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
| `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-oidc` | OIDC Authorization Code + PKCE flow |
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-oidc |
| `flash-extensions/flash-ext-security-core` | Security: authentication chain, annotations, sessions, OpenAPI |
| `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
| `flash-extensions/flash-ext-security-apikey` | API keys |
| `flash-extensions/flash-ext-security-form` | Password sign-in |
| `flash-extensions/flash-ext-security-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-jte` | Opinionated jte SSR extension |
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
| `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-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` |
| `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine |
## Requirements
@@ -139,12 +152,21 @@ FlashApp.create(8080)
```
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-oidc`](flash-extensions/flash-ext-oidc/README.md)
- [`flash-ext-security-core`](flash-extensions/flash-ext-security-core/docs/README.md)
- [`flash-ext-security-oidc`](flash-extensions/flash-ext-security-oidc/docs/README.md)
- [`flash-ext-security-apikey`](flash-extensions/flash-ext-security-apikey/docs/README.md)
- [`flash-ext-security-form`](flash-extensions/flash-ext-security-form/docs/README.md)
- [`flash-ext-security-test`](flash-extensions/flash-ext-security-test/docs/README.md)
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
- [`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-testing`](flash-testing/docs/README.md)
## Error handlers
@@ -389,6 +411,110 @@ The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing
HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a
future `flash-ext-grpc` extension.
## Testing
`flash-testing` boots a real app on an OS-assigned port for the duration of a test, and hands you
a client pointed at it. Add it with test scope:
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<version>${flash.version}</version>
<scope>test</scope>
</dependency>
```
```java
class UserRoutesTest {
@RegisterExtension
static FlashTest app = FlashTest.of(new BlogApp())
.mock(UserService.class, new InMemoryUserService());
@Test
void listsUsers() {
app.get("/api/users")
.expectStatus(200)
.expectHeader("content-type", "application/json")
.expectBodyContains("alice");
}
}
```
`FlashTest.of` takes a `FlashApplication` — your app's routes, extensions and services expressed
independently of which port they run on:
```java
public final class BlogApp implements FlashApplication {
@Override public void configure(FlashApp app) {
app.install(new JacksonExtension());
app.mount("/api", scope -> scope.scan("dev.blog.api"));
}
}
FlashApp.create(8080).apply(new BlogApp()).startAndBlock(); // production
```
It is a functional interface, so a lambda works too:
`FlashTest.of(app -> app.get("/ping", (req, res) -> "pong"))`.
### Requests
The HTTP verb sends the request; `expect*` assertions chain and report the real response body on
failure. `get` and `delete` skip the builder when there is nothing to add.
```java
app.get("/api/users").expectStatus(200);
app.request()
.header("Authorization", "Bearer " + token)
.json("{\"name\":\"bob\"}")
.post("/api/users")
.expectStatus(201);
try (FlashWebSocket socket = app.ws("/live")) {
socket.sendText("hello");
assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
}
```
### Replacing services
`mock` installs replacements after everything your app and its extensions declare, so a fake always
wins. Any object will do — `flash-testing` depends on no mocking library, so a hand-written fake and
a Mockito mock are equally welcome.
### More than one server
`FlashTest` is an ordinary object in a field, so a test class can hold as many as it needs and wire
one from another in plain Java. Startup is lazy — reading `baseUri()` boots that server on the spot
— so declaration order does the wiring:
```java
@RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
@RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri()));
```
### Scope
A `static` field boots once for the test class; a non-static field boots a fresh app for every test.
That is stock JUnit field semantics — the isolation switch is the keyword, not an option.
### Configuration
Full reference: [`flash-testing/docs`](flash-testing/docs/README.md), including the
[limits](flash-testing/docs/limits.md) the harness deliberately does not cross.
`profile` customises the `FlashConfiguration` — timeouts, HTTP/2 switches, buffer sizes. Host, port
and the shutdown drain window are stamped afterwards, so a profile cannot break the harness;
`listener(...)` and `tls(...)` are rejected because the harness owns the loopback listener it gives
you a client for.
```java
FlashTest.of(new BlogApp()).profile(cfg -> cfg.http2CleartextEnabled(true));
```
## Architecture
```
@@ -0,0 +1,85 @@
# flash-ext-cache-caffeine
In-process caching backed by [Caffeine](https://github.com/ben-manes/caffeine). Implements
[`flash-ext-cache-core`](../../flash-ext-cache-core/docs/README.md).
## Dependency
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-cache-caffeine</artifactId>
<version>${flash.version}</version>
</dependency>
```
## Quick start
```java
FlashApp.create(8080)
.install(new CaffeineCacheExtension())
.scan("dev.example.api");
```
```java
@GET("/api/users/{id}")
public final class GetUser extends RequestHandler {
private Cache<String, User> users;
private UserRepository repo;
@Override protected void onInit() {
repo = require(UserRepository.class);
users = require(CacheManager.class).build("users", spec -> spec
.maxSize(10_000)
.ttl(Duration.ofMinutes(10)));
}
@Override public Object handle(Request req, Response res) {
return users.get(req.param("id"), repo::findById);
}
}
```
The extension takes no configuration. Each cache declares its own size and TTL where it is built.
## Why Caffeine and not a `LinkedHashMap`
An LRU on top of `LinkedHashMap` is about sixty lines, and for a cache that is genuinely
low-traffic it is the right answer — `ConcurrentHashMap::computeIfAbsent` is one line and has no
hit rate to get wrong.
This module exists for the case where that stops being true. Caffeine's W-TinyLFU admission,
striped frequency counters and amortised eviction are not a weekend's work to reproduce, and the
failure mode of getting them wrong is a cache that is *slower* than no cache — lock contention on
every lookup, or an eviction policy that throws away exactly the entries you were about to want.
## Lifecycle
Caches are released through `FlashContext.onClose`, so `app.stop()` drops every entry. That is
invisible in production with one app per process and matters immediately under test, where many
apps start and stop in one JVM.
## Statistics
```java
CacheStats stats = users.stats();
stats.hitRate(); // 0.0 until something is looked up
```
Requires `recordStats()` on the spec. Without it you get `CacheStats.DISABLED`, which is honest
about being unmeasured rather than reporting zeroes that look like a cold cache.
`manager.names()` lists every cache built so far, for an ops endpoint.
## What this is not
**HTTP caching.** If what you want is for the *client* to stop asking — `Cache-Control`, `ETag`,
`304 Not Modified` — that is a middleware, not an object cache, and it saves the whole request
rather than the lookup inside it. Reach for that first: it is cheaper, and the two solve different
problems.
**A shared cache.** Every replica has its own. Two instances will hold different values for the
same key, and an invalidation on one does not reach the other. When that becomes a problem the
answer is a networked backend — see the note on `flash-ext-cache-redis` — and the semantics change
with it: a cache that can fail is no longer transparent.
@@ -0,0 +1,39 @@
<?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-cache-caffeine</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-cache-core</artifactId>
</dependency>
<!--
Caffeine rather than a hand-rolled LRU: W-TinyLFU admission, striped counters and
amortised eviction are not a weekend's work to get right, and getting them wrong is a
cache that is slower than no cache.
-->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,41 @@
package dev.relism.flash.ext.cache.caffeine;
import dev.relism.flash.ext.cache.Cache;
import dev.relism.flash.ext.cache.CacheStats;
import java.util.function.Function;
/**
* {@link Cache} over a Caffeine cache. A thin adapter by design: every method delegates directly,
* adding no wrapper object, no copy and no synchronisation of its own.
*/
final class CaffeineCache<K, V> implements Cache<K, V> {
private final com.github.benmanes.caffeine.cache.Cache<K, V> delegate;
private final boolean statsRecorded;
CaffeineCache(com.github.benmanes.caffeine.cache.Cache<K, V> delegate, boolean statsRecorded) {
this.delegate = delegate;
this.statsRecorded = statsRecorded;
}
@Override
public V get(K key, Function<? super K, ? extends V> loader) {
// Caffeine's own get(key, mappingFunction) already guarantees the loader runs once per key
// across concurrent callers; wrapping it in anything of ours would only add a race.
return delegate.get(key, loader);
}
@Override public V getIfPresent(K key) { return delegate.getIfPresent(key); }
@Override public void put(K key, V value) { delegate.put(key, value); }
@Override public void invalidate(K key) { delegate.invalidate(key); }
@Override public void invalidateAll() { delegate.invalidateAll(); }
@Override public long estimatedSize() { return delegate.estimatedSize(); }
@Override
public CacheStats stats() {
if (!statsRecorded) return CacheStats.DISABLED;
com.github.benmanes.caffeine.cache.stats.CacheStats snapshot = delegate.stats();
return new CacheStats(snapshot.hitCount(), snapshot.missCount(), snapshot.evictionCount());
}
}
@@ -0,0 +1,33 @@
package dev.relism.flash.ext.cache.caffeine;
import dev.relism.flash.ext.cache.CacheManager;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
/**
* Installs an in-process {@link CacheManager} backed by Caffeine.
*
* <pre>{@code
* FlashApp.create(8080)
* .install(new CaffeineCacheExtension())
* .scan("dev.example.api");
* }</pre>
*
* <p>No configuration. Each cache declares its own size and TTL where it is built, because those
* are properties of what is being cached, not of the process caching it.
*
* <p>Caches are dropped through {@link FlashContext#onClose}, so a stopped app does not keep its
* values alive — which matters when many apps start and stop in one JVM, as they do under test.
*/
public final class CaffeineCacheExtension implements FlashExtension {
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.supply(CacheManager.class, services -> {
CaffeineCacheManager manager = new CaffeineCacheManager();
services.onClose(manager::clear);
return manager;
});
}
}
@@ -0,0 +1,66 @@
package dev.relism.flash.ext.cache.caffeine;
import com.github.benmanes.caffeine.cache.Caffeine;
import dev.relism.flash.ext.cache.Cache;
import dev.relism.flash.ext.cache.CacheManager;
import dev.relism.flash.ext.cache.CacheSpec;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/** In-process {@link CacheManager} backed by Caffeine. */
final class CaffeineCacheManager implements CacheManager {
private final Map<String, Entry> caches = new ConcurrentHashMap<>();
@Override
@SuppressWarnings("unchecked")
public <K, V> Cache<K, V> build(String name, java.util.function.Consumer<CacheSpec> configure) {
CacheSpec spec = CacheSpec.of();
configure.accept(spec);
Entry entry = caches.computeIfAbsent(name, key -> new Entry(describe(spec), create(spec)));
// Two handlers sharing a cache is the point; two handlers disagreeing about its size or
// TTL is a bug that would otherwise resolve to whichever one ran first.
String requested = describe(spec);
if (!entry.signature.equals(requested))
throw new IllegalStateException("Cache '" + name + "' already exists as " + entry.signature
+ " but was requested as " + requested);
return (Cache<K, V>) entry.cache;
}
@Override
@SuppressWarnings("unchecked")
public <K, V> Cache<K, V> cache(String name) {
Entry entry = caches.get(name);
return entry == null ? null : (Cache<K, V>) entry.cache;
}
@Override
public Set<String> names() {
return Set.copyOf(caches.keySet());
}
/** Releases every entry so a stopped app does not keep its values alive. */
void clear() {
caches.values().forEach(entry -> entry.cache.invalidateAll());
caches.clear();
}
private static CaffeineCache<Object, Object> create(CacheSpec spec) {
Caffeine<Object, Object> builder = Caffeine.newBuilder();
if (spec.bounded()) builder.maximumSize(spec.maxSize());
if (spec.ttl() != null) builder.expireAfterWrite(spec.ttl());
if (spec.ttlAfterAccess() != null) builder.expireAfterAccess(spec.ttlAfterAccess());
if (spec.statsRecorded()) builder.recordStats();
return new CaffeineCache<>(builder.build(), spec.statsRecorded());
}
private static String describe(CacheSpec spec) {
return "maxSize=" + spec.maxSize() + " ttl=" + spec.ttl()
+ " ttlAfterAccess=" + spec.ttlAfterAccess() + " stats=" + spec.statsRecorded();
}
private record Entry(String signature, CaffeineCache<Object, Object> cache) {}
}
@@ -0,0 +1,160 @@
package dev.relism.flash.ext.cache.caffeine;
import dev.relism.flash.ext.cache.Cache;
import dev.relism.flash.ext.cache.CacheManager;
import dev.relism.flash.ext.cache.CacheStats;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class CaffeineCacheTest {
private static final AtomicInteger loads = new AtomicInteger();
@RegisterExtension
static FlashTest app = FlashTest.of(configured -> {
configured.install(new CaffeineCacheExtension());
configured.ctx().onReady(() -> {
Cache<String, String> users = configured.ctx().require(CacheManager.class)
.build("users", spec -> spec.maxSize(100).ttl(Duration.ofMinutes(5)).recordStats());
configured.get("/users/{id}", (req, res) ->
users.get(req.param("id"), id -> "loaded:" + id + ":" + loads.incrementAndGet()));
});
});
private static CacheManager manager() {
return app.app().ctx().require(CacheManager.class);
}
@Test
void aRepeatedRequestIsServedFromCache() {
String first = app.get("/users/alice").expectStatus(200).body();
String second = app.get("/users/alice").expectStatus(200).body();
assertEquals(first, second);
assertTrue(first.startsWith("loaded:alice:"));
}
@Test
void distinctKeysLoadSeparately() {
assertNotEquals(app.get("/users/bob").body(), app.get("/users/carol").body());
}
@Test
void statsCountHitsAndMisses() {
Cache<String, String> cache = manager().build("stats-probe", spec -> spec.maxSize(10).recordStats());
cache.get("k", key -> "v");
cache.get("k", key -> "v");
CacheStats stats = cache.stats();
assertEquals(1, stats.misses());
assertEquals(1, stats.hits());
assertEquals(0.5, stats.hitRate());
}
@Test
void statsAreDisabledUnlessAskedFor() {
Cache<String, String> cache = manager().build("no-stats", spec -> spec.maxSize(10));
cache.get("k", key -> "v");
assertEquals(CacheStats.DISABLED, cache.stats());
}
@Test
void theLoaderRunsOncePerKeyUnderConcurrency() throws Exception {
Cache<String, String> cache = manager().build("single-flight", spec -> spec.maxSize(10));
AtomicInteger invocations = new AtomicInteger();
int threads = 16;
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
for (int i = 0; i < threads; i++) {
Thread.ofVirtual().start(() -> {
try {
start.await();
cache.get("hot", key -> {
invocations.incrementAndGet();
return "value";
});
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
start.countDown();
assertTrue(done.await(5, java.util.concurrent.TimeUnit.SECONDS));
assertEquals(1, invocations.get(), "concurrent callers must share one load, not race");
}
@Test
void aNullLoaderResultStoresNothing() {
Cache<String, String> cache = manager().build("nulls", spec -> spec.maxSize(10));
assertNull(cache.get("missing", key -> null));
assertNull(cache.getIfPresent("missing"));
}
@Test
void invalidateDropsOneKeyAndInvalidateAllDropsEverything() {
Cache<String, String> cache = manager().build("invalidation", spec -> spec.maxSize(10));
cache.put("a", "1");
cache.put("b", "2");
cache.invalidate("a");
assertNull(cache.getIfPresent("a"));
assertEquals("2", cache.getIfPresent("b"));
cache.invalidateAll();
assertNull(cache.getIfPresent("b"));
}
@Test
void buildIsIdempotentPerName() {
Cache<String, String> first = manager().build("shared", spec -> spec.maxSize(10));
Cache<String, String> second = manager().build("shared", spec -> spec.maxSize(10));
assertSame(first, second, "two handlers asking for one cache must get one cache");
assertSame(first, manager().cache("shared"));
}
@Test
void disagreeingOnASharedCacheIsARejectedMistakeNotASilentWinner() {
manager().build("contested", spec -> spec.maxSize(10));
IllegalStateException conflict = assertThrows(IllegalStateException.class,
() -> manager().build("contested", spec -> spec.maxSize(999)));
assertTrue(conflict.getMessage().contains("contested"), conflict.getMessage());
}
@Test
void unknownNameReturnsNullRatherThanBuildingOne() {
assertNull(manager().cache("never-built"));
}
/** Why CaffeineCacheExtension registers onClose: values must not outlive the app holding them. */
@Test
void stoppingTheAppReleasesEveryCache() {
FlashApp standalone = FlashApp.create(FlashConfiguration.builder()
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build())
.install(new CaffeineCacheExtension());
standalone.start();
CacheManager manager = standalone.ctx().require(CacheManager.class);
manager.build("scoped", spec -> spec.maxSize(10)).put("k", "v");
assertEquals(1, manager.names().size());
standalone.stop().join();
assertTrue(manager.names().isEmpty(), "caches must be released when the app stops");
}
}
@@ -0,0 +1,64 @@
# flash-ext-cache-core
The caching contract, shared across backends. Like `flash-ext-data-core`, this module talks to
nothing: it defines the abstractions and a backend implements them.
## Components
- `Cache<K, V>` — a named cache. `get(key, loader)` is the method that matters.
- `CacheManager` — creates and hands back named caches.
- `CacheSpec` — size and expiry for one cache.
- `CacheStats` — hit/miss/eviction counters.
Install a backend, not this module: [`flash-ext-cache-caffeine`](../../flash-ext-cache-caffeine/docs/README.md)
for in-process caching.
## The one shape that matters
```java
User user = users.get(id, repo::findById);
```
Compute-if-absent is the only cache operation most code needs, and the only one that is hard to
get right — the loader runs **once per key** across concurrent callers, and the rest wait rather
than each computing their own. `getIfPresent`, `put`, `invalidate` and `invalidateAll` exist for
what it cannot express.
A loader returning `null` stores nothing and returns `null`. Caching absence is a decision, not a
default; wrap it in an `Optional` or a sentinel if you want it.
## Naming and sharing
`CacheManager.build(name, spec)` is idempotent per name: two handlers asking for `"users"` get one
cache, not two, so nobody has to coordinate who creates it first.
If they disagree about the spec, that throws. The alternative is a cache whose size depends on
which handler happened to initialise first, which is the kind of bug that only shows up under
load.
## Specs
```java
CacheSpec.of()
.maxSize(10_000)
.ttl(Duration.ofMinutes(10))
.recordStats();
```
Every field is optional, but a spec that sets neither `maxSize` nor `ttl` is an unbounded cache
that never expires — a memory leak wearing a hat. Set at least one.
`recordStats()` is off by default: counting costs a pair of atomic increments on every lookup, and
a cache nobody is measuring should not pay for numbers nobody reads. Without it, `stats()` returns
`CacheStats.DISABLED` rather than silently zero.
## Where the spec lives
On the cache, at the point it is built — not in application config. Size and TTL are properties of
*what is being cached*, not of the process doing the caching, and a TTL in a config file is a TTL
nobody can relate back to the data it governs.
## Writing a backend
Implement `CacheManager` and `Cache`, provide the manager from a `FlashExtension`, and register
cleanup with `FlashContext.onClose` so a stopped app does not keep its values alive.
@@ -0,0 +1,25 @@
<?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-cache-core</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,50 @@
package dev.relism.flash.ext.cache;
import java.util.function.Function;
/**
* A named cache. Obtained from a {@link CacheManager}, safe to hold in a handler field and share
* across threads.
*
* <pre>{@code
* User user = users.get(id, repo::findById);
* }</pre>
*
* <p>{@link #get} is the only method most code needs. The rest exist for the cases it cannot
* express: reading without populating, writing a value computed elsewhere, and invalidating.
*
* @param <K> key type — must have a stable {@code hashCode}/{@code equals}
* @param <V> value type
*/
public interface Cache<K, V> {
/**
* Returns the cached value, computing and storing it with {@code loader} if absent.
*
* <p>The loader runs at most once per key across concurrent callers; the others wait for it
* rather than each computing their own. A loader returning {@code null} stores nothing and
* {@code null} is returned.
*/
V get(K key, Function<? super K, ? extends V> loader);
/** The cached value, or {@code null} if absent. Never invokes a loader. */
V getIfPresent(K key);
/** Stores {@code value}, replacing any existing entry. */
void put(K key, V value);
/** Drops {@code key}. Does nothing if it was absent. */
void invalidate(K key);
/** Drops every entry. */
void invalidateAll();
/** Approximate entry count. Approximate because eviction is asynchronous in most backends. */
long estimatedSize();
/**
* Hit/miss counters since this cache was built, or {@link CacheStats#DISABLED} when the
* backend was not asked to record them.
*/
CacheStats stats();
}
@@ -0,0 +1,31 @@
package dev.relism.flash.ext.cache;
import java.util.function.Consumer;
/**
* Creates and hands back named caches. Resolve it with {@code require(CacheManager.class)}; a
* backend extension such as {@code flash-ext-cache-caffeine} provides the implementation.
*
* <pre>{@code
* @Override protected void onInit() {
* users = require(CacheManager.class).build("users", spec -> spec
* .maxSize(10_000)
* .ttl(Duration.ofMinutes(10)));
* }
* }</pre>
*
* <p>{@link #build} is idempotent per name: calling it twice returns the same cache rather than
* two, so several handlers can share one without coordinating who creates it. The spec of the
* first call wins; a later call with a different spec is a configuration mistake and throws.
*/
public interface CacheManager {
/** Creates the named cache, or returns the existing one. */
<K, V> Cache<K, V> build(String name, Consumer<CacheSpec> spec);
/** The named cache, or {@code null} if {@link #build} has not been called for it. */
<K, V> Cache<K, V> cache(String name);
/** Every cache name built so far, for an ops endpoint. */
java.util.Set<String> names();
}
@@ -0,0 +1,64 @@
package dev.relism.flash.ext.cache;
import java.time.Duration;
/**
* How one cache should behave. Every field is optional — a spec that sets nothing gives an
* unbounded cache that never expires, which is a memory leak wearing a hat, so set at least one
* of {@link #maxSize} or {@link #ttl}.
*
* <pre>{@code
* CacheSpec.of().maxSize(10_000).ttl(Duration.ofMinutes(10))
* }</pre>
*
* <p>Mutable builder rather than a record with {@code withX} copies: it is constructed once at
* boot inside a lambda and never shared.
*/
public final class CacheSpec {
private long maxSize = -1;
private Duration ttl;
private Duration ttlAfterAccess;
private boolean recordStats;
private CacheSpec() {}
public static CacheSpec of() {
return new CacheSpec();
}
/** Maximum entries before the backend starts evicting. Negative means unbounded. */
public CacheSpec maxSize(long maxSize) {
this.maxSize = maxSize;
return this;
}
/** Entries expire this long after they were written. */
public CacheSpec ttl(Duration ttl) {
this.ttl = ttl;
return this;
}
/** Entries expire this long after they were last read or written. */
public CacheSpec ttlAfterAccess(Duration ttlAfterAccess) {
this.ttlAfterAccess = ttlAfterAccess;
return this;
}
/**
* Records hit/miss counters for {@link Cache#stats()}.
*
* <p>Off by default: counting costs a pair of atomic increments on every lookup, and a cache
* nobody is measuring should not pay for numbers nobody reads.
*/
public CacheSpec recordStats() {
this.recordStats = true;
return this;
}
public long maxSize() { return maxSize; }
public Duration ttl() { return ttl; }
public Duration ttlAfterAccess() { return ttlAfterAccess; }
public boolean statsRecorded() { return recordStats; }
public boolean bounded() { return maxSize >= 0; }
}
@@ -0,0 +1,20 @@
package dev.relism.flash.ext.cache;
/**
* Hit/miss counters for one cache.
*
* @param hits lookups that found a value
* @param misses lookups that had to load
* @param evictions entries dropped to respect {@link CacheSpec#maxSize()}
*/
public record CacheStats(long hits, long misses, long evictions) {
/** Returned when {@link CacheSpec#recordStats()} was not set — all zero, and says so. */
public static final CacheStats DISABLED = new CacheStats(0, 0, 0);
/** Hits divided by lookups, or 0 when nothing has been looked up yet. */
public double hitRate() {
long total = hits + misses;
return total == 0 ? 0 : (double) hits / total;
}
}
@@ -0,0 +1,39 @@
# flash-ext-cache-redis — planned
Not implemented. This directory holds the design so the decision is written down rather than
rediscovered; there is deliberately **no module, no pom and no source**, because an empty module
that builds an empty jar is dead weight in the reactor and in everyone's dependency tree.
Add it when there is a second replica that actually needs shared state.
## What it would implement
`CacheManager` and `Cache` from [`flash-ext-cache-core`](../../flash-ext-cache-core/docs/README.md),
so switching backend is an install-line change:
```java
.install(new RedisCacheExtension(RedisConfig.of("redis://localhost:6379")))
```
## The part that is not a drop-in
`flash-ext-cache-caffeine` cannot fail. A networked cache can, and that changes the contract in
ways an adapter cannot hide:
- **`get(key, loader)` can fail before reaching the loader.** The honest default is to fall
through to the loader and serve the value uncached, so Redis being down degrades throughput
rather than taking the application with it. That has to be a decision, not an accident.
- **Values must be serialized.** Caffeine stores references. A `byte[]` codec belongs in the spec,
and the natural default is whatever `flash-ext-jackson` is already configured with.
- **`invalidateAll()` is not free.** Against a shared keyspace it is either a scan or a key
prefix per cache name. The prefix is the right answer, and it means cache names become part of
the wire contract.
- **Stats are per-client, not per-cache.** Hit rate stays meaningful; eviction count does not,
because Redis evicts on its own policy.
## Why it is not built yet
Nothing in the codebase has two replicas sharing cache state. Building it now would mean choosing
a client library, a serialization format and a failure policy with no real usage to check them
against — and the failure policy in particular is the kind of decision that is wrong until a
production incident tells you otherwise.
@@ -112,6 +112,8 @@ public abstract class Repository<T, ID> {
- `Tx` in the `FlashContext`
- `TxManager` in the `FlashContext`
- 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.
@@ -34,6 +34,7 @@ public final class DataExtension implements FlashExtension {
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.onClose(txManager::close);
ctx.provide(Tx.class, tx);
ctx.provide(TxManager.class, txManager);
if (data != null) ctx.provide(Data.class, data);
@@ -1,7 +1,11 @@
package dev.relism.flash.ext.data.core;
public interface TxManager {
public interface TxManager extends AutoCloseable {
TxStatus begin(TxDefinition definition);
void commit(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 org.hibernate.Session;
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;
@@ -16,6 +20,24 @@ public class HibernateTxManager implements TxManager {
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
public TxStatus begin(TxDefinition definition) {
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);
}
/** 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
public TxStatus begin(TxDefinition definition) {
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>
@@ -0,0 +1,76 @@
package dev.relism.flash.ext.jackson;
import java.lang.invoke.MethodHandle;
import java.util.regex.Pattern;
/**
* One constraint, compiled. Flattened into an opcode plus its operands rather than a class per
* constraint type: the check loop becomes a {@code tableswitch} over a monomorphic array instead
* of a megamorphic virtual call, and a passing check touches no allocation at all.
*
* <p>Field access goes through a {@link MethodHandle} adapted at compile time to an exact
* signature — {@code (Object)Object} for reference fields, {@code (Object)long} for primitive
* integrals — so {@code invokeExact} neither boxes nor allocates an argument array the way
* {@code Field.get} and {@code Method.invoke} do.
*/
final class Check {
static final int NOT_NULL = 0;
static final int NOT_BLANK = 1;
static final int NOT_EMPTY = 2;
static final int SIZE = 3;
static final int RANGE_PRIMITIVE = 4;
static final int RANGE_BOXED = 5;
static final int EMAIL = 6;
static final int PATTERN = 7;
final int op;
final String field;
/** Pre-rendered at compile time, so even the failure path formats nothing. */
final String message;
/** {@code (Object)Object} — set for every op except {@link #RANGE_PRIMITIVE}. */
final MethodHandle ref;
/** {@code (Object)long} — set only for {@link #RANGE_PRIMITIVE}. */
final MethodHandle num;
final int min;
final int max;
final long lo;
final long hi;
final Pattern pattern;
private Check(int op, String field, String message, MethodHandle ref, MethodHandle num,
int min, int max, long lo, long hi, Pattern pattern) {
this.op = op;
this.field = field;
this.message = message;
this.ref = ref;
this.num = num;
this.min = min;
this.max = max;
this.lo = lo;
this.hi = hi;
this.pattern = pattern;
}
static Check reference(int op, String field, String message, MethodHandle ref) {
return new Check(op, field, message, ref, null, 0, 0, 0, 0, null);
}
static Check size(String field, String message, MethodHandle ref, int min, int max) {
return new Check(SIZE, field, message, ref, null, min, max, 0, 0, null);
}
static Check rangePrimitive(String field, String message, MethodHandle num, long lo, long hi) {
return new Check(RANGE_PRIMITIVE, field, message, null, num, 0, 0, lo, hi, null);
}
static Check rangeBoxed(String field, String message, MethodHandle ref, long lo, long hi) {
return new Check(RANGE_BOXED, field, message, ref, null, 0, 0, lo, hi, null);
}
static Check pattern(String field, String message, MethodHandle ref, Pattern pattern) {
return new Check(PATTERN, field, message, ref, null, 0, 0, 0, 0, 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);
}
};
}
}
@@ -0,0 +1,39 @@
package dev.relism.flash.ext.jackson;
import dev.relism.flash.exceptions.HttpException;
import java.util.List;
/**
* Raised when a value fails its constraints. Extends {@link HttpException} with status 422, so
* Flash's default exception handler renders it without this extension registering anything.
*
* <p>Allocated only on failure — a passing validation constructs nothing.
*/
public final class ValidationException extends HttpException {
private final transient List<Violation> violations;
public ValidationException(List<Violation> violations) {
super(422, describe(violations));
this.violations = List.copyOf(violations);
}
/** The individual failures, in field declaration order. */
public List<Violation> violations() {
return violations;
}
private static String describe(List<Violation> violations) {
StringBuilder out = new StringBuilder(32 * violations.size());
for (int i = 0; i < violations.size(); i++) {
if (i > 0) out.append("; ");
Violation v = violations.get(i);
out.append(v.field()).append(' ').append(v.message());
}
return out.toString();
}
/** One failed constraint. */
public record Violation(String field, String message) {}
}
@@ -0,0 +1,255 @@
package dev.relism.flash.ext.jackson;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* The compiled constraints of one type. Built once per class and reused for every request.
*
* <p>{@link #verify} allocates nothing when a value passes: the loop walks an array (no iterator),
* reads fields through exact-signature {@link MethodHandle}s (no boxing, no argument array), and
* compares against operands resolved at compile time. The violation list and the exception are
* constructed only once something actually fails.
*/
public final class Validator {
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 Validator(Check[] 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. */
public boolean isEmpty() {
return checks.length == 0;
}
/**
* Verifies every constraint on {@code target}.
*
* @throws ValidationException with all failures, never just the first
*/
public void verify(Object target) {
List<ValidationException.Violation> failures = null;
for (Check check : checks) {
if (passes(check, target)) continue;
if (failures == null) failures = new ArrayList<>(4);
failures.add(new ValidationException.Violation(check.field, check.message));
}
if (failures != null) throw new ValidationException(failures);
}
private static boolean passes(Check check, Object target) {
try {
if (check.op == Check.RANGE_PRIMITIVE) {
long value = (long) check.num.invokeExact(target);
return value >= check.lo && value <= check.hi;
}
Object value = (Object) check.ref.invokeExact(target);
// Jakarta semantics: only @NotNull rejects null; every other constraint passes it.
return switch (check.op) {
case Check.NOT_NULL -> value != null;
case Check.NOT_BLANK -> value instanceof String text && !text.isBlank();
case Check.NOT_EMPTY -> value != null && sizeOf(value) > 0;
case Check.SIZE -> value == null || withinSize(check, value);
case Check.RANGE_BOXED -> value == null || withinRange(check, (Number) value);
case Check.EMAIL -> value == null || (value instanceof String text && isEmail(text));
case Check.PATTERN -> value == null
|| (value instanceof String text && check.pattern.matcher(text).matches());
default -> true;
};
} catch (Throwable failure) {
throw new IllegalStateException("Could not read " + check.field + " for validation", failure);
}
}
private static boolean withinSize(Check check, Object value) {
int size = sizeOf(value);
return size >= check.min && size <= check.max;
}
private static boolean withinRange(Check check, Number value) {
long asLong = value.longValue();
return asLong >= check.lo && asLong <= check.hi;
}
/** No copies: every branch reads a length the object already knows. */
private static int sizeOf(Object value) {
if (value instanceof CharSequence text) return text.length();
if (value instanceof Collection<?> items) return items.size();
if (value instanceof Map<?, ?> entries) return entries.size();
if (value instanceof Object[] array) return array.length;
return 1;
}
/**
* Structural check rather than a regex: {@code Pattern.matcher} allocates a matcher, an int
* array and a group array on every call, which is exactly the per-request cost this module
* exists to avoid. {@code indexOf} allocates nothing.
*
* <p>Accepts what a mail server would plausibly route and rejects the shapes people actually
* typo. Deliverability is the confirmation mail's job, not a validator's.
*/
private static boolean isEmail(String value) {
int at = value.indexOf('@');
if (at <= 0 || at == value.length() - 1) return false;
if (value.indexOf('@', at + 1) >= 0) return false;
int dot = value.indexOf('.', at + 2);
return dot > 0 && dot < value.length() - 1 && value.indexOf(' ') < 0;
}
// ── Compilation ──────────────────────────────────────────────────────────
/**
* Compiles {@code type}'s constraints once.
*
* <p>Reads declared fields rather than record accessors: a constraint on a record component
* propagates to the backing field, so records and plain classes need one code path, not two.
*/
static Validator compile(Class<?> type) {
MethodHandles.Lookup lookup;
try {
lookup = MethodHandles.privateLookupIn(type, MethodHandles.lookup());
} catch (IllegalAccessException denied) {
throw new IllegalStateException(
"Cannot read " + type.getName() + " for validation — open its module or package", denied);
}
List<Check> checks = new ArrayList<>();
for (Field field : type.getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers())) continue;
MethodHandle getter;
try {
getter = lookup.unreflectGetter(field);
} catch (IllegalAccessException denied) {
continue;
}
compileField(field, getter, checks);
}
return new Validator(checks.isEmpty() ? NONE : checks.toArray(new Check[0]));
}
private static void compileField(Field field, MethodHandle getter, List<Check> checks) {
String name = field.getName();
Class<?> type = field.getType();
MethodHandle ref = type.isPrimitive() ? null : asReference(getter);
NotNull notNull = field.getAnnotation(NotNull.class);
if (notNull != null && ref != null)
checks.add(Check.reference(Check.NOT_NULL, name, said(notNull.message(), "must not be null"), ref));
NotBlank notBlank = field.getAnnotation(NotBlank.class);
if (notBlank != null && ref != null)
checks.add(Check.reference(Check.NOT_BLANK, name, said(notBlank.message(), "must not be blank"), ref));
NotEmpty notEmpty = field.getAnnotation(NotEmpty.class);
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);
if (size != null && ref != null)
checks.add(Check.size(name, said(size.message(), sizeMessage(size)), ref, size.min(), size.max()));
Min min = field.getAnnotation(Min.class);
Max max = field.getAnnotation(Max.class);
if (min != null || max != null) {
long lo = min != null ? min.value() : Long.MIN_VALUE;
long hi = max != null ? max.value() : Long.MAX_VALUE;
String message = said(min != null ? min.message() : max.message(), rangeMessage(min, max));
if (isIntegralPrimitive(type)) {
checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi));
} else if (Number.class.isAssignableFrom(type) && ref != null) {
checks.add(Check.rangeBoxed(name, message, ref, lo, hi));
}
}
Email email = field.getAnnotation(Email.class);
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);
if (pattern != null && ref != null) {
// 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.
checks.add(Check.pattern(name, said(pattern.message(), "must match " + pattern.regexp()), ref,
java.util.regex.Pattern.compile(pattern.regexp())));
}
}
private static boolean isIntegralPrimitive(Class<?> type) {
return type == int.class || type == long.class || type == short.class || type == byte.class;
}
private static MethodHandle asReference(MethodHandle getter) {
return getter.asType(MethodType.methodType(Object.class, Object.class));
}
private static MethodHandle asLong(MethodHandle getter) {
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) {
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();
return "size must be between " + size.min() + " and " + size.max();
}
private static String rangeMessage(Min min, Max max) {
if (min == null) return "must be at most " + max.value();
if (max == null) return "must be at least " + min.value();
return "must be between " + min.value() + " and " + max.value();
}
}
@@ -0,0 +1,128 @@
package dev.relism.flash.ext.jackson;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class ValidatorTest {
record CreateUser(
@NotBlank @Size(max = 8) String name,
@Email String email,
@Min(18) @Max(120) int age,
@NotNull String role) {}
record Boxed(@Min(1) Integer count) {}
record Sized(@NotEmpty List<String> tags, @Size(min = 2, max = 4) String code) {}
record Patterned(@Pattern(regexp = "[a-z]+") String slug) {}
record Plain(String anything) {}
private static ValidationException failureOf(Object value) {
return assertThrows(ValidationException.class, () -> Validator.compile(value.getClass()).verify(value));
}
@Test
void aValidValuePasses() {
assertDoesNotThrow(() ->
Validator.compile(CreateUser.class).verify(new CreateUser("alice", "a@b.com", 30, "admin")));
}
@Test
void reportsEveryViolationNotJustTheFirst() {
ValidationException failure = failureOf(new CreateUser(" ", "nope", 5, null));
assertEquals(List.of("name", "email", "age", "role"),
failure.violations().stream().map(ValidationException.Violation::field).toList());
}
@Test
void violationsCarryFieldAndMessage() {
ValidationException failure = failureOf(new CreateUser("alice", "a@b.com", 5, "admin"));
assertEquals(1, failure.violations().size());
assertEquals("age", failure.violations().get(0).field());
assertEquals("must be between 18 and 120", failure.violations().get(0).message());
assertEquals(422, failure.status());
assertEquals("age must be between 18 and 120", failure.getMessage());
}
@Test
void sizeCountsCharactersWithoutCopying() {
assertEquals("name", failureOf(new CreateUser("far-too-long", "a@b.com", 30, "x"))
.violations().get(0).field());
}
@Test
void onlyNotNullRejectsNull() {
// @Email, @Size and @Min all accept null per Jakarta semantics; @NotNull is the one that does not.
ValidationException failure = failureOf(new CreateUser("alice", null, 30, null));
assertEquals(List.of("role"),
failure.violations().stream().map(ValidationException.Violation::field).toList());
}
@Test
void boxedNumbersUseTheReferencePathAndTolerateNull() {
assertDoesNotThrow(() -> Validator.compile(Boxed.class).verify(new Boxed(null)));
assertEquals("count", failureOf(new Boxed(0)).violations().get(0).field());
}
@Test
void sizeAppliesToCollectionsAndStrings() {
assertDoesNotThrow(() -> Validator.compile(Sized.class).verify(new Sized(List.of("a"), "abc")));
ValidationException failure = failureOf(new Sized(List.of(), "x"));
assertEquals(List.of("tags", "code"),
failure.violations().stream().map(ValidationException.Violation::field).toList());
}
@Test
void patternIsAnchoredLikeJakarta() {
assertDoesNotThrow(() -> Validator.compile(Patterned.class).verify(new Patterned("abc")));
assertEquals("slug", failureOf(new Patterned("Abc1")).violations().get(0).field());
}
@Test
void emailAcceptsPlausibleAddressesAndRejectsTypos() {
assertDoesNotThrow(() ->
Validator.compile(CreateUser.class).verify(new CreateUser("a", "first.last@sub.example.co", 20, "x")));
for (String bad : List.of("no-at", "@leading.com", "trailing@", "two@@at.com", "no dots@x", "a@b")) {
assertThrows(ValidationException.class,
() -> Validator.compile(CreateUser.class).verify(new CreateUser("a", bad, 20, "x")),
bad);
}
}
@Test
void aTypeWithNoConstraintsCompilesToANoOp() {
Validator validator = Validator.compile(Plain.class);
assertTrue(validator.isEmpty());
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;
}
}
@@ -0,0 +1,67 @@
package dev.relism.flash.ext.jackson.json;
import dev.relism.flash.ext.jackson.json.JsonExtension;
import dev.relism.flash.ext.openapi.APIResponse;
import dev.relism.flash.ext.openapi.ApiOperation;
import dev.relism.flash.ext.openapi.Content;
import dev.relism.flash.ext.openapi.OpenApiExtension;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.GET;
import dev.relism.flash.testing.FlashTest;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
/**
* Constraints are declared once and read twice: the validator enforces them, the published schema
* describes them. Nothing registers this bridge — flash-ext-openapi picks the annotations up on
* its own when they are on the classpath.
*/
class ConstraintsInTheDocumentTest {
record Account(
@NotBlank @Size(max = 40) String name,
@Email String email,
@Min(18) @Max(120) int age) {}
@GET("/accounts")
@ApiOperation(summary = "List accounts")
@APIResponse(responseCode = "200", content = @Content(schema = Account.class))
public static class ListAccounts extends RequestHandler {
@Override public Object handle(Request request, Response response) {
return new Account("alice", "a@b.com", 30);
}
}
@RegisterExtension
static FlashTest app = FlashTest.of(configured -> {
configured.install(new JsonExtension());
configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0"));
configured.scan("dev.relism.flash.ext.jackson.json");
});
@Test
void constraintsAppearInTheGeneratedSchema() {
app.get("/openapi.json")
.expectStatus(200)
.expectBodyContains("\"maxLength\":40")
.expectBodyContains("\"format\":\"email\"")
.expectBodyContains("\"minimum\":18")
.expectBodyContains("\"maximum\":120");
}
@Test
void notBlankMarksThePropertyRequiredAndNonEmpty() {
app.get("/openapi.json")
.expectStatus(200)
.expectBodyContains("\"minLength\":1")
.expectBodyContains("\"required\":[\"name\"]");
}
}
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.jackson;
package dev.relism.flash.ext.jackson.json;
import com.fasterxml.jackson.databind.ObjectMapper;
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.assertTrue;
class JacksonExtensionTest {
class JsonExtensionTest {
@Test
void configure_registers_json_mapper_and_middleware() {
FlashContext ctx = new FlashContext();
ObjectMapper mapper = new ObjectMapper();
JacksonExtension ext = new JacksonExtension(mapper);
JsonExtension ext = new JsonExtension(mapper);
ext.configure(null, ctx);
ctx.complete();
assertNotNull(ctx.require(Json.class));
assertNotNull(ctx.require(JacksonMiddleware.class));
assertSame(mapper, ctx.require(ObjectMapper.class));
}
@Test
void autoJson_factory_delegates_to_middleware_policy() throws Exception {
void auto_marshals_what_a_handler_returns() throws Exception {
ObjectMapper mapper = new ObjectMapper();
JacksonExtension ext = new JacksonExtension(mapper);
JsonExtension ext = new JsonExtension(mapper);
RequestHandler next = new RequestHandler() {
@Override
public Object handle(Request request, Response response) {
@@ -43,7 +42,7 @@ class JacksonExtensionTest {
}
};
RequestHandler wrapped = new RequestHandler() {
private final SimpleHandler.FunctionalHandler delegate = ext.autoJson().wrap(next);
private final SimpleHandler.FunctionalHandler delegate = ext.auto().wrap(next);
@Override
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,11 +1,11 @@
package dev.relism.flash.ext.jackson;
package dev.relism.flash.ext.jackson.json;
import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.HeaderMap;
import dev.relism.flash.models.Http1HeaderMap;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestLine;
import dev.relism.flash.models.Response;
@@ -37,16 +37,11 @@ class JsonTest {
}
@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());
Request ok = request("{\"id\":\"u2\",\"name\":\"bob\"}");
UserDto dto = json.bodyFrom(ok, UserDto.class);
assertEquals("u2", dto.id);
assertEquals("bob", dto.name);
HttpException ex = assertThrows(HttpException.class, () -> json.body(request("["), UserDto.class));
Request bad = request("[");
HttpException ex = assertThrows(HttpException.class, () -> json.bodyFrom(bad, UserDto.class));
assertEquals(400, ex.status());
}
@@ -82,7 +77,7 @@ class JsonTest {
new FastPathViews.StringByteView("/json"),
null,
new FastPathViews.StringByteView("HTTP/1.1"),
new HeaderMap()
new Http1HeaderMap()
);
return new Request(line, body);
}
@@ -1,6 +1,8 @@
package dev.relism.flash.ext.jackson;
package dev.relism.flash.ext.jackson.json;
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.http.ContentType;
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.assertTrue;
class JacksonMiddlewareTest {
class MarshallingTest {
private static final Request REQ = null;
@Test
void autoJson_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
void marshalling_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
Response res = new Response(200, ContentType.TEXT_PLAIN);
@@ -34,8 +36,8 @@ class JacksonMiddlewareTest {
}
@Test
void autoJson_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
void marshalling_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
RequestHandler wrappedResponse = wrap(mw, payloadResponse);
@@ -55,17 +57,17 @@ class JacksonMiddlewareTest {
}
@Test
void autoJson_wraps_serialization_errors_as_illegal_state() {
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
void marshalling_wraps_serialization_errors_as_illegal_state() {
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
RequestHandler wrapped = wrap(mw, new CyclicDto());
Response res = new Response(200, ContentType.TEXT_PLAIN);
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res));
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() {
@Override
public Object handle(Request request, Response response) {
@@ -73,7 +75,7 @@ class JacksonMiddlewareTest {
}
};
return new RequestHandler() {
private final SimpleHandler.FunctionalHandler delegate = mw.autoJson().wrap(next);
private final SimpleHandler.FunctionalHandler delegate = mw.wrap(next);
@Override
public Object handle(Request request, Response response) throws Exception {
@@ -0,0 +1,67 @@
package dev.relism.flash.ext.jackson.json;
import dev.relism.flash.testing.FlashTest;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
/** The whole path: JSON in, constraints checked, status out — with no error handling wired up. */
class ValidatedBodyTest {
record CreateUser(@NotBlank @Size(max = 8) String name, @Email String email, @Min(18) int age) {}
@RegisterExtension
static FlashTest app = FlashTest.of(configured -> {
configured.install(new JsonExtension());
configured.ctx().onReady(() -> {
Json json = configured.ctx().require(Json.class);
configured.post("/users", (req, res) ->
res.status(201).body("created:" + json.body(req, CreateUser.class).name()));
});
});
@Test
void validBodyReachesTheHandler() {
app.request().json("{\"name\":\"alice\",\"email\":\"a@b.com\",\"age\":30}").post("/users")
.expectStatus(201)
.expectBody("created:alice");
}
@Test
void constraintViolationBecomes422WithEveryFailureListed() {
app.request().json("{\"name\":\"\",\"email\":\"nope\",\"age\":5}").post("/users")
.expectStatus(422)
.expectHeader("Content-Type", "application/json")
.expectBodyContains("name must not be blank")
.expectBodyContains("email must be a well-formed email address")
.expectBodyContains("age must be at least 18");
}
@Test
void malformedJsonBecomes400NotAValidationFailure() {
app.request().json("not json").post("/users")
.expectStatus(400)
.expectBodyContains("Invalid request body");
}
/** Regression guard: HttpException used to reach the catch-all and come back as 500. */
@Test
void statusCarriedByTheExceptionSurvivesToTheWire() {
assertEquals(422, app.request().json("{\"name\":\"x\",\"email\":\"a@b.com\",\"age\":1}")
.post("/users").status());
}
@Test
void errorBodyIsValidJsonEvenWhenTheMessageContainsQuotes() {
app.request().json("{\"name\":\"waaaaaaaaaay-too-long\",\"email\":\"a@b.com\",\"age\":30}").post("/users")
.expectStatus(422)
.expectBodyContains("\"status\":422")
.expectBodyContains("size must be at most 8");
}
}
@@ -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.
@@ -0,0 +1,43 @@
<?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-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>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-jackson-core</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</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
LimiterConfig conf = new LimiterConfig()
.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous");
FlashApp.create(8080)
.install(new LimiterExtension(conf))
@@ -35,11 +35,11 @@ conf.registerResolver("ip", req -> {
LimiterConfig conf = new LimiterConfig();
```
### By authenticated user (OIDC / ClaimsHolder)
### By authenticated user
```java
conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous");
```
Requests from unauthenticated users share the `"anonymous"` bucket. If you want
@@ -88,7 +88,7 @@ returns the same key for the same user regardless of endpoint; the limit is set
```java
conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anon");
SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anon");
```
```java
@@ -11,7 +11,7 @@ import dev.relism.flash.models.Request;
*
* <pre>{@code
* conf.registerResolver("ip", req -> req.header("X-Forwarded-For"));
* conf.registerResolver("auth_user", req -> ClaimsHolder.user().sub());
* conf.registerResolver("auth_user", req -> SecurityIdentity.current().principal().name());
* }</pre>
*/
@FunctionalInterface
@@ -21,8 +21,8 @@ import java.util.Map;
* <pre>{@code
* LimiterConfig conf = new LimiterConfig()
* .registerResolver("auth_user", req -> {
* // custom logic — e.g. extract sub from ClaimsHolder
* return ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous";
* // custom logic — e.g. key by the authenticated caller
* return SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous";
* });
*
* app.install(new LimiterExtension(conf));
@@ -43,7 +43,7 @@ import java.util.Map;
* <h3>Lambda routes (via Guard)</h3>
* <pre>{@code
* app.install(new LimiterExtension(
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
* new LimiterConfig().registerResolver("auth_user", req -> SecurityIdentity.current().principal().name())));
*
* // inside a FlashContext.onReady(...) callback:
* Guard guard = ctx.require(Guard.class);
@@ -3,7 +3,7 @@
`flash-ext-mcp` turns a Flash5 app into an [MCP](https://modelcontextprotocol.io) (Model Context
Protocol) server: JSON-RPC 2.0 over the Streamable HTTP transport, tools/resources/prompts
declared as plain classes and discovered at boot, optional OAuth2 protection built on
`flash-ext-oidc`.
`flash-ext-security-core`.
## Quick Start
@@ -44,7 +44,7 @@ public class GetWeatherTool extends McpTool {
`tools-resources-prompts.md`.
- **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
for exactly what that means and why.
- **Security**: optional, policy-driven OAuth2 via `flash-ext-oidc` — see `security.md`.
- **Security**: authenticated by `flash-ext-security-core`, an OAuth2 protected resource when OIDC is installed — see `security.md`.
- **JSON**: this extension owns its JSON handling independently of `flash-ext-jackson` — see
`jackson-interop.md` for why, and how a future opt-in reuse could work.
@@ -53,6 +53,4 @@ public class GetWeatherTool extends McpTool {
- [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts
- [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation
- [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707
- [`keycloak.md`](keycloak.md) — Keycloak-specific setup cookbook: Dynamic Client Registration,
the RFC 8707 audience mapper gotcha, and how to verify/debug it
- [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson`
@@ -24,7 +24,7 @@ see `tools-resources-prompts.md` — and the fixed `TextContent`/`TextResourceCo
as a `JsonNode` tree, not as a databound class, for the same reason — a JSON-RPC tool call's
arguments aren't a DTO with getters/setters, they're a dynamic, per-tool-defined bag of values.
This mirrors how `flash-ext-oidc` already handles its own internal JSON needs (`json-smart` for
This mirrors how `flash-ext-security-oidc` handles its own JSON needs (Nimbus's parser for
token-endpoint responses) independently of `flash-ext-jackson` — extensions with protocol-level
JSON needs that are shaped by a spec, not by user code, own that JSON handling themselves rather
than routing it through the app's general-purpose JSON extension.
@@ -44,8 +44,7 @@ Nothing here rules out a later, additive convenience layer: `McpExtension.routes
that shared mapper as the backing for an escape hatch such as `ToolArguments.as(Class<T>)` or
for a tool that wants to `ToolResponse.success(someRecord)` and have it serialized with the
app's own conventions — falling back to a locally-constructed default `ObjectMapper` when
`flash-ext-jackson` isn't installed, the same "prefer shared, degrade to sane default" shape
already used for `McpSecurity.AUTO`. That would be purely additive on top of the
`flash-ext-jackson` isn't installed. That would be purely additive on top of the
`JsonGenerator`-based envelope/content writing described above, not a replacement for it — the
fixed-shape protocol plumbing has no reason to ever go through databinding, regardless of what
convenience layer gets added around it.
@@ -1,107 +0,0 @@
# Keycloak cookbook
`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any
`flash-ext-oidc`-compatible provider. This is the Keycloak-specific setup: the exact Admin
Console configuration for a working MCP OAuth2 flow with open Dynamic Client Registration
(DCR) — no pre-registered clients, any MCP client self-registers on first connect.
## 1. Allow Dynamic Client Registration
MCP clients (Claude Desktop, Claude.ai, MCP Inspector, others) don't share one static OAuth
client — each has its own `redirect_uri` and none know your realm in advance. They self-register
on first connect via `POST {issuer}/clients-registrations/openid-connect` (the
`registration_endpoint` from the AS metadata document, reached via the RFC 9728 Protected
Resource Metadata document `McpExtension` publishes).
**Clients → Client registration**: remove the **Trusted Hosts** policy — it rejects anonymous
registration from hosts not on an explicit allowlist (`403` / `"Host not trusted"`), which
doesn't scale to arbitrary future agents. This does not weaken end-user authentication — DCR
only grants an app a `client_id`; every user still authenticates against Keycloak's real login
screen regardless of which client asked. Lighter hygiene policies (**Max Clients Limit**,
**Consent Required**) can stay, they don't interfere.
## 2. RFC 8707 audience: mapper on `basic`, not a custom scope
`McpOidcIntegration` rejects (403) any token whose `aud` doesn't include the MCP endpoint's
canonical URL. Keycloak doesn't add this by default. The obvious fix — a custom client scope
with an Audience mapper, marked Default, added to Allowed Client Scopes — **does not work**:
clients created via the `openid-connect` DCR endpoint only ever get scopes they explicitly
request, and most MCP clients (including MCP Inspector) don't request anything beyond what a
server tells them to via `scopes_supported` (step 3). Default-scope auto-attachment, which is
how a normal manually-created client would pick up a custom Default scope, doesn't apply to
DCR-created clients at all.
`basic` is the one built-in scope Keycloak attaches to every client unconditionally, regardless
of what it registered with. Put the audience mapper there:
1. **Client scopes → `basic`****Mappers****Add mapper****By configuration**
**Audience**.
2. **Included Custom Audience** = the exact value your server expects — check
`GET {parent-of-rootPath}/.well-known/oauth-protected-resource{rootPath}` on the running
server for the `resource` field it publishes (auto-derived from the request's
forwarded/`Host` headers — see `security.md`). Leave **Included Client Audience** empty (that
targets another Keycloak client, not a resource URL).
3. **Add to access token** = ON.
4. **Save.**
This is unconditional and works regardless of client cooperation — keep it even after step 3
below gets other claims flowing normally, since audience binding is a hard spec requirement
that shouldn't depend on a client bothering to request the right scope.
## 3. Other claims (username, email...): `scopes_supported` + Allowed Client Scopes
`OidcUser.username()`/`.email()`/`.name()` read `preferred_username`/`email`/`name` — normally
from the `profile`/`email` client scopes, which DCR clients don't get either, same root cause.
Unlike audience, this **is** fixable the "normal" way, because it doesn't need to survive a
completely uncooperative client:
`McpConfig.scopesSupported("openid", "profile", "email")` publishes those scopes in the PRM
document. MCP clients that read it (confirmed for MCP Inspector) echo them back in their DCR
registration request — `"scope": "openid profile email offline_access"` (`offline_access` is
Inspector's own addition, for refresh tokens). For that request to actually succeed, **Allowed
Client Scopes** needs, exactly:
- **`openid` listed explicitly.** The one genuinely non-obvious step: `openid` is not covered by
**Allow Default Scopes** (On by default) the way other realm-Default scopes are, even though
every OIDC request includes it. Until it's listed here, registration fails with a generic
`403 insufficient_scope` / `"Not permitted to use specified clientScope"` regardless of
whether everything else is configured correctly.
- **`offline_access` listed explicitly** — it's Optional, not Default, so `ALLOW_DEFAULT_SCOPES`
doesn't cover it either.
- **`profile`/`email` — do not list them here.** Mark them **Default** on the **Client scopes**
page (Assigned Type column) instead, and leave **Allow Default Scopes** = On. Adding an
already-Default scope to this list explicitly gets rejected on save
(`"Client scopes not allowed: [...]"`) — the list is for *additional* Optional scopes only.
With that, a real client's token comes back with `preferred_username`/`email` populated
normally.
### Fallback for anything else
For a claim not covered by `openid profile email` (a custom attribute, a role) — or for a client
that ignores `scopes_supported` entirely — add a **User Property** mapper to `basic` too
(Property `username` → Token Claim Name `preferred_username`, or whatever's needed), same as the
audience mapper in step 2. Unconditional, works regardless of client cooperation, costs one
mapper per claim, once, at the realm level — not per tool.
## Verifying without a full OAuth round-trip
**Clients → (any client) → Client scopes → Evaluate**: pick a user, run it — Default scopes
(including `basic`) apply automatically and won't appear in the "Select scope parameters"
picker, which only lists Optional ones — and check the **Generated Access Token** preview.
Confirms mappers work without a browser + real MCP client round-trip each time.
## If a real client still gets rejected
`McpOidcIntegration.audienceGuard` logs the actual mismatch at `WARN`:
```
[flash-ext-mcp] Rejecting token (RFC 8707): aud=<token's actual aud> does not include expected
resource identifier "<what this server expects>" — ...
```
`aud=null` → the `basic` mapper produced nothing (most common cause: **Included Custom
Audience** left blank — the mapper saves fine and silently does nothing without it). A non-null
`aud` that still doesn't match → compare byte-for-byte — the expected side is derived from the
request's own forwarded/`Host` headers, so scheme/host/trailing-slash mismatches show up here
directly, as does a proxy hop that drops `X-Forwarded-Host`.
+40 -152
View File
@@ -1,170 +1,58 @@
# Security
Provider-specific setup steps (not generic OAuth2 mechanics) live in separate cookbooks —
[`keycloak.md`](keycloak.md) for Keycloak: enabling Dynamic Client Registration, why the RFC 8707
audience mapper needs to go on the built-in `basic` scope instead of a custom one, and the exact
Allowed Client Scopes configuration `scopes_supported` needs to actually work.
The MCP endpoint is secured by [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md):
whatever mechanisms the application registers — OAuth2 bearer tokens, API keys, custom ones —
authenticate `/mcp` exactly as they authenticate every other route.
## `McpSecurity`
| `McpConfig.security(...)` | |
|---|---|
| `REQUIRED` (default) | every call must be authenticated; boot fails without a `SecurityExtension` |
| `NONE` | a public endpoint; a tool carrying security annotations fails the boot |
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-oidc` being
installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExtension.routes()`:
## OAuth2 protected resource
| Policy | `flash-ext-oidc` installed | `flash-ext-oidc` absent |
|---|---|---|
| `REQUIRED` | protected | **boot fails** (`IllegalStateException`) |
| `AUTO` (default) | protected | runs unprotected, logs a warning |
| `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected |
When a registered mechanism publishes an OAuth2 issuer — `flash-ext-security-oidc` does — the endpoint
behaves as the MCP authorization spec requires, with nothing to configure:
Use `REQUIRED` for anything you intend to run in production reachable over the network — it
turns "someone forgot to wire up OAuth2" into a startup crash instead of a silently open
endpoint. `AUTO` is meant for local development, where spinning up a real identity provider is
friction you don't want yet.
- `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata: the `resource` (the
application's `SecurityExtension.origin(...)` plus the path), every issuer as `authorization_servers`,
and `scopes_supported` when `McpConfig.scopesSupported(...)` is set;
- an anonymous call gets `401` with `WWW-Authenticate: Bearer resource_metadata="…"`;
- a token whose `aud` does not include the resource is `403` (RFC 8707) and logged at `WARN`. Credentials
that are not audience-bound, such as API keys, are unaffected.
## Why `flash-ext-oidc` is an *optional* Maven dependency, concretely
For Keycloak, the audience comes from an *Audience* protocol mapper whose included custom audience is
the resource URL, attached to a client scope every MCP client receives (the built-in `basic` scope is the
one that needs no client cooperation). Clients that register dynamically need Keycloak's anonymous
client registration policies relaxed for the trusted hosts.
Maven's `<optional>true</optional>` only affects **transitive** propagation: consumers of
`flash-ext-mcp` don't get `flash-ext-oidc` pulled in automatically unless they add it themselves.
Within `flash-ext-mcp` itself, `flash-ext-oidc`'s classes are on the compile/test classpath as
normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in
source.
`McpConfig.requireTokenAudience(false)` drops that last check for an authorization server that cannot
mint a resource audience at all — Keycloak ignores RFC 8707's `resource` parameter, so a deployment that
cannot add the mapper has no other way in. Every token a registered issuer signs is then accepted on the
endpoint, and the boot logs say so.
That reference is isolated in its own class, `McpOidcIntegration`, invoked only from inside a
`catch (NoClassDefFoundError)` block. A bare class-literal like `OidcMiddleware.class` (which
`ctx.find(OidcMiddleware.class)` needs) forces the JVM to resolve that type the moment it's
evaluated — if `flash-ext-oidc` is not on the *runtime* classpath at all (a genuinely
MCP-only install, no OAuth2 anywhere in the app), the first such reference throws
`NoClassDefFoundError`. Keeping that reference inside a separate, lazily-loaded class means
`McpExtension` itself loads and works fine standalone; only the attempt to actually use OIDC
fails, and only when there's something to fail. This mirrors `OidcExtension`'s own lazy bridge to
`flash-ext-openapi` — same technique, same reason.
## Which credentials
## OAuth2 resolution details — zero-config by default
When oidc is available and `security() != NONE`, `McpOidcIntegration` (an isolated,
lazily-loaded bridge — see its javadoc) derives everything an MCP OAuth2 resource server needs
straight from the installed `OidcMiddleware`, with no additional `McpConfig` calls required:
1. The MCP route is wrapped with `flash-ext-oidc`'s own `OidcMiddleware.protect(resourceMetadataPath)`
— the same Bearer-token/JWKS validation path used everywhere else in Flash5, plus a
`resource_metadata` challenge parameter (see below). No JWT parsing or JWKS handling is
reimplemented here.
2. An audience guard always runs after `protect(...)`: it reads the validated claims from
`ClaimsHolder` and rejects (`403`) any token whose `aud` claim does not include the resource
identifier — **RFC 8707 Resource Indicators / audience binding**, enforced unconditionally,
not opt-in. `OidcMiddleware` itself validates `aud` against its own `clientId` for ID
tokens, but deliberately does not enforce audience on access tokens (it varies by provider)
— the MCP extension adds that check on top, scoped to its own resource identifier.
3. The resource identifier is the canonical URI of the MCP endpoint, resolved **per request** by
`OidcMiddleware#selfOrigin` + `rootPath` — the same scheme/host resolution `OidcExtension`
uses for its own redirect URIs: `X-Forwarded-Host`/`X-Forwarded-Proto` when the request came
through a reverse proxy, otherwise `{selfScheme()}://{Host header}`. Behind a proxy the
`Host` alone is the upstream address the proxy dialled, which would publish a resource
identifier no client can reach. `McpConfig.resourceIdentifier(...)` still overrides it
outright for a proxy that forwards neither header.
4. The authorization server issuer is read from `OidcMiddleware#issuer()` unless
`McpConfig.authorizationServerIssuer(...)` overrides it.
## RFC 9728 Protected Resource Metadata
Whenever the endpoint ends up protected, `flash-ext-mcp` publishes a Protected Resource Metadata
document at `/.well-known/oauth-protected-resource{rootPath}` — no explicit `resourceIdentifier`/
`authorizationServerIssuer` configuration required, both are auto-derived as described above:
```json
{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/realms/myrealm"] }
```
`resource` is computed per request from the incoming request's forwarded/`Host` headers (see
above), so the document is correct without hardcoding the server's own public URL.
### `scopes_supported`
Optional per RFC 9728, omitted from the document entirely unless set via
`McpConfig.scopesSupported("openid", "profile", "email")`:
```json
{ "resource": "...", "authorization_servers": ["..."], "scopes_supported": ["openid", "profile", "email"] }
```
This is pure advertisement — token validation doesn't change based on it — but it matters in
practice: a client that ignores it and requests no scope at all (many do — see `keycloak.md`)
only gets back whatever the authorization server treats as always-included regardless of
request, which for Keycloak is just its built-in `basic` scope. A client that *does* read
`scopes_supported` and echoes it back in its authorization/token requests gets a token with the
claims those scopes actually provide (`profile``preferred_username`/`name`, etc.), without
needing every one of those claims hand-mapped onto `basic`. Set it to whatever scopes your
`McpTool`s actually read off `ClaimsHolder`/`OidcUser` — there's no way to auto-derive this list,
it depends entirely on what your tools do with the claims.
## `WWW-Authenticate: resource_metadata` (RFC 9728 §5.1)
The MCP Authorization spec **requires** a `401` to carry `resource_metadata` in
`WWW-Authenticate`, pointing at the Protected Resource Metadata document above — this is how a
spec-compliant client discovers the authorization server without out-of-band configuration.
`OidcMiddleware.protect(String resourceMetadataPath)` (an overload added specifically for this)
builds that challenge automatically:
```
WWW-Authenticate: Bearer realm="...", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
```
The plain `OidcMiddleware.protect()` (no argument), used by every other Flash5 app, is
unaffected — this parameter is additive and MCP-specific.
## Per-tool `@RolesAllowed`/`@ScopesAllowed`
`McpTool` subclasses can carry `flash-ext-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
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,
and only their issuers are published — so a client is sent to exactly the authorization server the
endpoint trusts.
```java
@Tool(name = "delete_route", description = "Delete a route")
@RolesAllowed("admin")
public class DeleteRouteTool extends McpTool {
@Override public ToolResponse call(ToolArguments args) { ... }
}
McpConfig.builder("app").toolsPackage("com.example.tools").mechanisms(authorizationServer).build();
```
This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
the thing that makes these annotations work on a `RequestHandler`) — it can't: every tool shares
one HTTP route (`POST {rootPath}`), already wrapped by whatever `McpSecurity` resolved above, so
there is no per-tool route to attach a different middleware chain to. Instead,
`McpOidcIntegration.compileToolPolicy` reads the annotations once at boot (`McpRegistry.scan`)
and compiles them into a closure (`McpAuthPolicy`) that `McpDispatcher` runs *after* the
route-wide auth has already succeeded and *before* invoking the specific tool named in the
`tools/call` request — narrowing what's already-authenticated, not replacing it. A denial is a
normal `isError: true` tool result (see `ToolResponse.error`), not an HTTP-level rejection — the
model sees why, the same as any other tool failure.
## Tool policies
Roles are read via `OidcUser#hasRole` against `McpConfig.rolesClaimPath(...)` (default
`"realm_access.roles"`, matching `OidcConfig`'s own default — set this explicitly if the two
diverge; there's no way to read `OidcConfig`'s actual configured value from here). Scopes use
`OidcUser#hasScope`'s built-in default claim paths (`scope`/`scp`), no extra config needed.
`@ScopesAllowed(match = ScopesAllowed.Match.ANY)` and multi-role `@RolesAllowed({"admin",
"editor"})` (OR semantics) both work exactly as they do on a `RequestHandler`.
The core annotations work on tools as on handlers, checked per `tools/call` against the caller the route
authenticated:
**`@Authenticated` alone has no effect and fails boot.** Once oidc is active for a server, every
tool call is already authenticated — there's no per-tool public/authenticated split the way
there is for HTTP routes, so a bare `@Authenticated` on a tool can't mean anything and would
silently do nothing if allowed to compile. Boot fails instead, with a message pointing at
`@RolesAllowed`/`@ScopesAllowed` as the actual narrowing mechanism.
```java
@Tool(name = "approve", description = "Approves a pending proposal")
@RolesAllowed(value = "REVIEWER", on = {"project", "locale"}) // read from the tool's arguments
public class ApproveTool extends McpTool { }
```
**Annotating a tool without active OAuth2 also fails boot**, not silently at request time: if
`@RolesAllowed`/`@ScopesAllowed`/`@Authenticated` shows up on a tool while `McpSecurity` resolved
to unprotected (`NONE`, or `AUTO` with no oidc installed), that's very likely a forgotten
`OidcExtension` install or a `McpSecurity.NONE` left over from local dev — `IllegalStateException`
at `app.start()`.
A denial is a tool result with `isError: true` — the call reached the server, the tool did not run.
## The `HttpException` safety net
`flash-ext-oidc`'s middleware throws `HttpException.unauthorized()`/`forbidden()` on auth
failure. Flash5's core does **not** special-case `HttpException` in the default exception
handler — the out-of-the-box `AbstractRouter` default always returns a generic `500`, regardless
of the thrown exception's embedded status code; only an app that explicitly calls
`FlashApp#onException(...)` (or installs something that does) gets `HttpException.status()`
honored.
To keep the MCP endpoint correct regardless of what the rest of the app configures,
`McpTransportGuards.httpExceptionGuard()` wraps the whole route and translates `HttpException`
into the right HTTP status itself, rather than letting it fall through to the app's (possibly
unconfigured) global handler. This is scoped entirely to the MCP route — it does not touch or
override the app's `onException` for any other route.
`McpConfig.middleware(...)` runs after authentication, for rate limiting, auditing or tracing.
+22 -2
View File
@@ -19,8 +19,7 @@
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId>
<optional>true</optional>
<artifactId>flash-ext-security-core</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
@@ -38,6 +37,27 @@
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-test</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-oidc</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-apikey</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -1,23 +0,0 @@
package dev.relism.flash.ext.mcp;
import java.util.function.Supplier;
/**
* Compiled per-tool authorization requirement, built once at boot by {@link McpOidcIntegration}
* from {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} subclass {@code null}
* on {@link McpRegistry.RegisteredTool} means no restriction beyond whatever {@link McpSecurity}
* already enforces route-wide.
*
* <p>{@code check} is a closure, not a raw role/scope list this is what lets this record (and
* its only caller, {@link McpDispatcher}) stay free of any compile-time reference to a {@code
* flash-ext-oidc} type, preserving the same classload isolation {@link McpOidcIntegration}'s
* javadoc describes for the rest of the OIDC bridge. Only the plain-JDK {@link Supplier}
* signature crosses the boundary; the closure itself, built once inside {@code
* McpOidcIntegration}, is the only place that ever touches {@code OidcUser}/{@code ClaimsHolder}.
*
* <p>Returns {@code null} from {@link #check()}{@code .get()} when authorized, or a
* human-readable denial reason otherwise invoked once per {@code tools/call} against an
* annotated tool, never allocated on that path (the closure and its captured role/scope arrays
* are built exactly once, at boot).
*/
record McpAuthPolicy(Supplier<String> check) {}
@@ -1,5 +1,8 @@
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.List;
@@ -24,10 +27,11 @@ public final class McpConfig {
private final String rootPath;
private final String toolsPackage;
private final McpSecurity security;
private final String resourceIdentifier;
private final String authorizationServerIssuer;
private final boolean requireTokenAudience;
private final List<String> allowedOrigins;
private final List<String> scopesSupported;
private final List<Middleware> middleware;
private final List<AuthenticationMechanism> mechanisms;
private McpConfig(Builder b) {
this.name = b.name;
@@ -36,10 +40,11 @@ public final class McpConfig {
this.rootPath = b.rootPath;
this.toolsPackage = b.toolsPackage;
this.security = b.security;
this.resourceIdentifier = b.resourceIdentifier;
this.authorizationServerIssuer = b.authorizationServerIssuer;
this.requireTokenAudience = b.requireTokenAudience;
this.allowedOrigins = List.copyOf(b.allowedOrigins);
this.scopesSupported = List.copyOf(b.scopesSupported);
this.middleware = List.copyOf(b.middleware);
this.mechanisms = List.copyOf(b.mechanisms);
}
String name() { return name; }
@@ -48,10 +53,11 @@ public final class McpConfig {
String rootPath() { return rootPath; }
String toolsPackage() { return toolsPackage; }
McpSecurity security() { return security; }
String resourceIdentifier() { return resourceIdentifier; }
String authorizationServerIssuer() { return authorizationServerIssuer; }
boolean requireTokenAudience() { return requireTokenAudience; }
List<String> allowedOrigins() { return allowedOrigins; }
List<String> scopesSupported() { return scopesSupported; }
List<Middleware> middleware() { return middleware; }
List<AuthenticationMechanism> mechanisms() { return mechanisms; }
public static Builder builder(String name) { return new Builder(name); }
@@ -61,11 +67,12 @@ public final class McpConfig {
private String instructions;
private String rootPath = "/mcp";
private String toolsPackage;
private McpSecurity security = McpSecurity.AUTO;
private String resourceIdentifier;
private String authorizationServerIssuer;
private McpSecurity security = McpSecurity.REQUIRED;
private boolean requireTokenAudience = true;
private final List<String> allowedOrigins = new ArrayList<>();
private final List<String> scopesSupported = new ArrayList<>();
private final List<Middleware> middleware = new ArrayList<>();
private final List<AuthenticationMechanism> mechanisms = new ArrayList<>();
private Builder(String name) {
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. */
public Builder toolsPackage(String toolsPackage) { this.toolsPackage = toolsPackage; return this; }
/** OAuth2 requirement policy. Default {@link McpSecurity#AUTO}. */
/** Default {@link McpSecurity#REQUIRED}. */
public Builder security(McpSecurity security) { this.security = security; return this; }
/**
* Canonical URI of this MCP endpoint, used for RFC 8707 audience binding: tokens whose
* {@code aud} claim does not include this value are rejected. Optional when
* {@code flash-ext-oidc} is installed, this is auto-derived per request from the
* forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own
* redirect URIs) and audience binding is enforced unconditionally. Set this explicitly
* only to override that guess a reverse proxy that forwards neither
* {@code X-Forwarded-Host} nor {@code X-Forwarded-Proto}.
* Whether a bearer token must name this endpoint in its {@code aud} (RFC 8707), as the MCP
* authorization spec requires. Default {@code true}. Turn it off for an authorization server
* that cannot mint a resource audience every token a registered issuer signs is then
* accepted on the endpoint, and a warning is logged at boot.
*/
public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; }
/**
* Authorization server issuer URL, published in the RFC 9728 Protected Resource
* Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}}. Optional
* when {@code flash-ext-oidc} is installed, this is auto-derived from its configured
* issuer. Set this explicitly only to override that (e.g. publishing a different issuer
* than the one actually validating tokens).
*/
public Builder authorizationServerIssuer(String issuer) { this.authorizationServerIssuer = issuer; return this; }
public Builder requireTokenAudience(boolean require) { this.requireTokenAudience = require; return this; }
/**
* Origins allowed to call the MCP endpoint (DNS-rebinding protection, per the Streamable
@@ -115,19 +110,24 @@ public final class McpConfig {
*/
public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; }
/**
* OAuth2 scopes this server expects clients to request, published as {@code
* scopes_supported} in the RFC 9728 Protected Resource Metadata document. Optional per
* the spec omitted from the document entirely if never set. A spec-compliant client
* reads this to know what to put in its authorization/token requests instead of
* requesting nothing; see {@code docs/keycloak.md}'s "same story for any other claim"
* section for why this matters in practice (a client that requests no scope only gets
* whatever your authorization server treats as always-included, e.g. Keycloak's `basic`).
* Purely advertisement this server still validates whatever token it actually receives
* the same way regardless of what a client requested.
*/
/** Published as {@code scopes_supported} in the RFC 9728 metadata, so OAuth clients request them. */
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
/**
* 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() {
if (toolsPackage == null || toolsPackage.isBlank())
throw new IllegalStateException(
@@ -3,6 +3,8 @@ package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonNode;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.ext.security.SecurityPolicy;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
@@ -18,10 +20,10 @@ import java.io.IOException;
* tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object,
* always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only
* malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. A
* {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link McpAuthPolicy}) is the same
* {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link SecurityPolicy}) is the same
* category {@code isError: true}, tool never invoked not a transport-level rejection; the
* route-wide 401/403 for "not authenticated at all" already happened earlier, in the {@code
* OidcMiddleware}/audience-guard middleware chain, before this dispatcher ever runs.
* route-wide 401/403 already happened earlier, in the security middleware, before this
* dispatcher ever runs.
*/
final class McpDispatcher {
@@ -136,12 +138,14 @@ final class McpDispatcher {
if (tool == null)
throw McpProtocolException.invalidParams("Unknown tool: " + name);
ToolResponse result;
String denied = tool.policy() != null ? tool.policy().check().get() : null;
if (denied != null) {
result = ToolResponse.error("Tool \"" + name + "\" denied: " + denied);
} else {
ToolArguments args = new ToolArguments(params.path("arguments"));
SecurityPolicy policy = tool.policy();
ToolResponse result;
if (policy != null && !policy.permitsScopes(SecurityIdentity.current())) {
result = ToolResponse.error("Tool \"" + name + "\" denied: missing scope");
} else if (policy != null && !policy.permitsRoles(SecurityIdentity.current(), args::getString)) {
result = ToolResponse.error("Tool \"" + name + "\" denied: missing role");
} else {
try {
result = tool.instance().call(args);
} catch (Exception e) {
@@ -1,5 +1,12 @@
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.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
@@ -9,43 +16,31 @@ import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* MCP (Model Context Protocol) server extension. Streamable HTTP transport a single
* {@code POST} JSON-RPC endpoint, stateless in this revision (no session, no SSE stream; see
* {@code docs/transport.md}) dispatch precompiled at boot from classes annotated with
* {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} under
* MCP (Model Context Protocol) server extension. Streamable HTTP transport a single {@code POST}
* JSON-RPC endpoint, stateless in this revision (see {@code docs/transport.md}) dispatching to
* {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes under
* {@link McpConfig#toolsPackage(String)}.
*
* <pre>{@code
* // Standalone, no OAuth2
* FlashApp.create(8080)
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
* .toolsPackage("com.example.tools")
* .build()))
* .start();
*
* // With flash-ext-oidc as the OAuth2 resource server zero extra config: issuer, canonical
* // resource identifier, RFC 8707 audience binding and RFC 9728 metadata are all derived from
* // the installed OidcExtension.
* FlashApp.create(8080)
* .install(new OidcExtension(oidcConfig))
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
* .toolsPackage("com.example.tools")
* .security(McpSecurity.REQUIRED)
* .build()))
* .start();
* app.install(new SecurityExtension())
* .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)))
* .install(new McpExtension(McpConfig.builder("my-server").toolsPackage("com.example.tools").build()));
* }</pre>
*
* <p>One server per {@code McpExtension} instance install multiple instances (distinct
* {@code rootPath}, distinct {@code toolsPackage}) for multiple MCP servers on one app,
* mirroring the {@code OidcExtension} multi-tenant pattern. See {@code docs/security.md} for
* the full OAuth2 resolution rules.
* <p>Every call is authenticated by the application's security chain OAuth2 bearer tokens, API
* keys, anything registered. When an OAuth2 issuer is among its schemes, the endpoint is also an
* OAuth2 protected resource: RFC 9728 metadata, a {@code resource_metadata} challenge, and RFC 8707
* audience binding for audience-bound tokens. Tool annotations are enforced per call, with
* {@code @RolesAllowed(on = ...)} reading tool arguments.
*/
@Slf4j
public class McpExtension implements FlashExtension {
private final McpConfig config;
private volatile List<SecurityScheme> schemes;
public McpExtension(McpConfig config) {
this.config = config;
@@ -53,68 +48,67 @@ public class McpExtension implements FlashExtension {
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.onReady(() -> registerRoutes(app, ctx));
}
ctx.onReady(() -> {
SecurityExtension security = config.security() == McpSecurity.NONE ? null : ctx.find(SecurityExtension.class)
.orElseThrow(() -> new IllegalStateException("MCP server \"" + config.name()
+ "\" requires flash-ext-security-core: install a SecurityExtension, or set McpSecurity.NONE for a public server"));
McpDispatcher dispatcher = new McpDispatcher(McpRegistry.scan(config.toolsPackage(), ctx, security),
config.name(), config.version(), config.instructions());
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
// Resolved before scanning so McpRegistry knows, per tool, whether @RolesAllowed/
// @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration
// (see McpOidcIntegration#compileToolPolicy) must run first, not after.
McpOidcIntegration.Resolved secured = resolveSecurity(ctx);
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null,
secured == null ? null : secured.rolesClaimPath());
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
List<Middleware> chain = new ArrayList<>(3);
chain.add(McpTransportGuards.httpExceptionGuard());
chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
if (secured != null) chain.add(secured.security());
app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; },
chain.toArray(Middleware[]::new));
registerResourceMetadata(app, secured);
}
private McpOidcIntegration.Resolved resolveSecurity(FlashContext ctx) {
if (config.security() == McpSecurity.NONE) return null;
McpOidcIntegration.Resolved resolved;
try {
resolved = McpOidcIntegration.resolve(ctx, config);
} catch (NoClassDefFoundError e) {
resolved = null; // flash-ext-oidc not on the classpath at all
}
if (resolved != null) return resolved;
if (config.security() == McpSecurity.REQUIRED) {
throw new IllegalStateException(
"McpSecurity.REQUIRED but flash-ext-oidc is not installed for MCP server \"" + config.name() +
"\" — install an OidcExtension before this McpExtension, or relax security to " +
"McpSecurity.AUTO/NONE if this server is meant to be public.");
}
log.warn("[flash-ext-mcp] MCP server \"{}\" is running WITHOUT OAuth2 protection — " +
"flash-ext-oidc is not installed and McpSecurity.AUTO degrades to unprotected. " +
"Install flash-ext-oidc or set McpSecurity.REQUIRED to make this a hard failure instead.",
config.name());
List<Middleware> chain = new ArrayList<>(List.of(
McpTransportGuards.httpExceptionGuard(), McpTransportGuards.originGuard(config.allowedOrigins())));
if (security != null) protect(app, security, chain);
chain.addAll(config.middleware());
app.post(config.rootPath(), (req, res) -> {
dispatcher.handle(req, res);
return null;
}
/**
* RFC 9728 Protected Resource Metadata, built once security is resolved no longer
* conditioned on {@code resourceIdentifier}/{@code authorizationServerIssuer} being set
* explicitly, since {@link McpOidcIntegration#resolve} now derives both by default. The
* {@code resource} field is computed per request (it depends on that request's own
* forwarded/{@code Host} headers) via {@link McpOidcIntegration.Resolved#resourceIdentifier()}.
*/
private void registerResourceMetadata(FlashRegistrar<?> app, McpOidcIntegration.Resolved secured) {
if (secured == null) return;
String path = "/.well-known/oauth-protected-resource" + config.rootPath();
app.get(path, (req, res) -> {
res.type(ContentType.JSON);
return McpResourceMetadata.build(
secured.resourceIdentifier().apply(req), secured.issuer(), config.scopesSupported());
}, chain.toArray(Middleware[]::new));
});
}
private void protect(FlashRegistrar<?> app, SecurityExtension security, List<Middleware> chain) {
String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
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
* protocol plumbing, not a user-facing serialization concern, so this extension owns its
* mapper independently same reasoning {@code flash-ext-oidc} applies to its own JSON needs
* mapper independently the same reasoning any protocol-level extension applies to its own JSON needs
* (see {@code json-smart} there). See {@code docs/jackson-interop.md} for the full rationale
* and how a future opt-in reuse of a shared {@code ObjectMapper} could work.
*/
@@ -1,180 +0,0 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.oidc.Authenticated;
import dev.relism.flash.ext.oidc.ClaimsHolder;
import dev.relism.flash.ext.oidc.OidcMiddleware;
import dev.relism.flash.ext.oidc.OidcUser;
import dev.relism.flash.ext.oidc.RolesAllowed;
import dev.relism.flash.ext.oidc.ScopesAllowed;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.Request;
import dev.relism.flash.routing.Middleware;
import lombok.extern.slf4j.Slf4j;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Lazy, isolated bridge to {@code flash-ext-oidc}.
*
* <p>References to OIDC types only ever resolve when {@link #resolve}/{@link #compileToolPolicy}
* are actually invoked never at {@link McpExtension} class-load time because they live in
* this separate nested class. The caller wraps the invocation in {@code catch
* (NoClassDefFoundError)}, exactly like {@code OidcExtension}'s own lazy bridge to {@code
* flash-ext-openapi}. This is what lets {@code flash-ext-mcp} run standalone (MCP-only, no
* OAuth2) when {@code flash-ext-oidc} is not even on the classpath. {@link Resolved}/{@link
* McpAuthPolicy} carry only oidc-free types back out ({@link Middleware}, {@link String}, a
* {@link Function}, a {@link Supplier}) so no other class in this package ever has to reference
* an OIDC type.
*
* <p>Zero-config by design: when {@code flash-ext-oidc} is installed, everything an MCP OAuth2
* resource server needs issuer, canonical resource identifier, RFC 8707 audience binding, and
* a spec-compliant {@code WWW-Authenticate} challenge (RFC 9728 §5.1) is derived straight from
* the installed {@link OidcMiddleware}, with no additional {@link McpConfig} calls.
* {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)}
* remain as explicit overrides for the rare case where that guess is wrong.
*/
@Slf4j
final class McpOidcIntegration {
private static final String[] NO_VALUES = new String[0];
private McpOidcIntegration() {}
/** Everything {@link McpExtension} needs once oidc security is resolved. */
record Resolved(Middleware security, String issuer, String rolesClaimPath,
Function<Request, String> resourceIdentifier) {}
/** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
static Resolved resolve(FlashContext ctx, McpConfig config) {
Optional<OidcMiddleware> oidc = ctx.find(OidcMiddleware.class);
if (oidc.isEmpty()) return null;
OidcMiddleware oidcMw = oidc.get();
String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
String issuer = config.authorizationServerIssuer() != null
? config.authorizationServerIssuer() : oidcMw.issuer();
Function<Request, String> resourceId = req -> config.resourceIdentifier() != null
? config.resourceIdentifier()
: OidcMiddleware.selfOrigin(req, oidcMw.selfScheme()) + config.rootPath();
Middleware protect = oidcMw.protect(resourceMetadataPath);
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
return new Resolved(secured, issuer, oidcMw.rolesClaimPath(), resourceId);
}
/**
* RFC 8707 audience binding, unconditionally enforced once oidc is protecting the MCP
* route no longer opt-in behind an explicit {@code resourceIdentifier(...)} call.
*/
private static Middleware audienceGuard(Function<Request, String> resourceIdentifier) {
return next -> (req, res) -> {
Map<String, Object> claims = ClaimsHolder.get();
String expected = resourceIdentifier.apply(req);
if (claims != null && !audienceMatches(claims.get("aud"), expected)) {
log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " +
"resource identifier \"{}\" — the authorization server must include this exact " +
"value in the access token's aud claim (e.g. an Audience protocol mapper in " +
"Keycloak) for this MCP server to accept it.", claims.get("aud"), expected);
throw HttpException.forbidden();
}
return next.handle(req, res);
};
}
private static boolean audienceMatches(Object aud, String expected) {
if (aud instanceof String s) return s.equals(expected);
if (aud instanceof Iterable<?> it) {
for (Object o : it) if (expected.equals(String.valueOf(o))) return true;
}
return false;
}
/**
* Compiles {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool class into a {@link
* McpAuthPolicy}, or returns {@code null} if the tool carries none of the three OIDC
* annotations. Called once per tool at boot ({@link McpRegistry#scan}), never on the
* request hot path the {@link Supplier} it returns is what runs per {@code tools/call},
* closing over the already-normalized role/scope arrays so the hot path itself allocates
* nothing beyond what {@link OidcUser#hasRole}/{@link OidcUser#hasScope} already do.
*
* <p>Fails fast at boot, not silently at request time, for the two ways this can be
* misconfigured: the annotation present without OAuth2 actually protecting this MCP server
* ({@code oidcActive == false}), and {@code @Authenticated} which has no per-tool meaning
* here (see below) used at all.
*/
static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> toolClass, boolean oidcActive,
String rolesClaimPath) {
Authenticated auth = toolClass.getAnnotation(Authenticated.class);
RolesAllowed roles = toolClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = toolClass.getAnnotation(ScopesAllowed.class);
if (auth == null && roles == null && scopes == null) return null;
if (!oidcActive) {
throw new IllegalStateException(
"MCP tool \"" + toolClass.getSimpleName() + "\" declares @Authenticated/@RolesAllowed/" +
"@ScopesAllowed, but this MCP server has no active OAuth2 protection — flash-ext-oidc " +
"is not installed for it, or McpSecurity is NONE. These annotations require " +
"McpSecurity.AUTO/REQUIRED with an OidcExtension installed; install one, or remove the " +
"annotation from " + toolClass.getSimpleName() + ".");
}
if (auth != null) {
throw new IllegalStateException(
"MCP tool \"" + toolClass.getSimpleName() + "\" is annotated @Authenticated, which has " +
"no effect on an McpTool: the whole MCP endpoint is already all-or-nothing " +
"authenticated once oidc is active (McpSecurity.AUTO/REQUIRED) — unlike a RequestHandler " +
"route, there is no per-tool public/authenticated split to opt into. Remove it, or use " +
"@RolesAllowed/@ScopesAllowed to narrow further.");
}
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : NO_VALUES;
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : NO_VALUES;
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
Supplier<String> check = () -> {
OidcUser user = ClaimsHolder.user();
if (user == null) return "not authenticated";
if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles))
return "missing required role (any of: " + String.join(", ", requiredRoles) + ")";
if (requiredScopes.length > 0 && !hasScopes(user, requiredScopes, scopeMatch))
return "missing required scope (" + scopeMatch + " of: " + String.join(", ", requiredScopes) + ")";
return null;
};
return new McpAuthPolicy(check);
}
private static boolean hasAnyRole(OidcUser user, String claimPath, String[] roles) {
for (String role : roles) if (user.hasRole(claimPath, role)) return true;
return false;
}
private static boolean hasScopes(OidcUser user, String[] scopes, ScopesAllowed.Match match) {
if (match == ScopesAllowed.Match.ALL) {
for (String scope : scopes) if (!user.hasScope(scope)) return false;
return true;
}
for (String scope : scopes) if (user.hasScope(scope)) return true;
return false;
}
/** Mirrors {@code OidcAuthPolicy}'s own normalization — trim, dedupe, require non-blank. */
private static String[] normalizeRequired(String annotationName, String[] values) {
if (values == null || values.length == 0)
throw new IllegalStateException("@" + annotationName + " requires at least one value");
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
for (String raw : values) {
if (raw == null) continue;
String trimmed = raw.trim();
if (!trimmed.isEmpty()) normalized.add(trimmed);
}
if (normalized.isEmpty())
throw new IllegalStateException("@" + annotationName + " requires at least one non-empty value");
return normalized.toArray(String[]::new);
}
}
@@ -2,6 +2,8 @@ package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonGenerator;
import dev.relism.flash.exceptions.InitializationException;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityPolicy;
import dev.relism.flash.extension.FlashContext;
import java.io.IOException;
@@ -25,7 +27,7 @@ final class McpRegistry {
private static final String EMPTY_ARRAY = "[]";
/** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */
record RegisteredTool(String name, McpTool instance, McpAuthPolicy policy) {}
record RegisteredTool(String name, McpTool instance, SecurityPolicy policy) {}
record RegisteredResource(String uri, McpResource instance) {}
record RegisteredPrompt(String name, McpPrompt instance) {}
@@ -39,15 +41,8 @@ final class McpRegistry {
private McpRegistry() {}
/**
* @param oidcActive whether this MCP server's route is actually OAuth2-protected right
* now (see {@link McpOidcIntegration#resolve}) gates whether
* {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or
* rejected at boot as a misconfiguration; see
* {@link McpOidcIntegration#compileToolPolicy}.
* @param rolesClaimPath claim path resolved from the installed OIDC extension.
*/
static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) {
/** @param security {@code null} for a server running with {@link McpSecurity#NONE} */
static McpRegistry scan(String packageName, FlashContext ctx, SecurityExtension security) {
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
McpRegistry registry = new McpRegistry();
@@ -55,7 +50,9 @@ final class McpRegistry {
Tool ann = cls.getAnnotation(Tool.class);
McpTool instance = instantiate(cls);
instance.bind(ctx);
McpAuthPolicy policy = compileToolPolicy(cls, oidcActive, rolesClaimPath);
if (security == null && SecurityPolicy.of(cls) != null)
throw new InitializationException("MCP tool \"" + ann.name() + "\" declares security annotations, but the server runs with McpSecurity.NONE");
SecurityPolicy policy = security == null ? null : security.policy(cls);
if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null)
throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\"");
}
@@ -173,24 +170,6 @@ final class McpRegistry {
gen.writeEndArray();
}
/**
* Isolated the same way {@link McpOidcIntegration#resolve} is {@code
* NoClassDefFoundError} here means {@code flash-ext-oidc} genuinely isn't on the runtime
* classpath, in which case a tool couldn't have been compiled against
* {@code @RolesAllowed}/{@code @ScopesAllowed} in the first place, so there's nothing to
* check (and nothing lost: {@code oidcActive} is only ever {@code true} once {@link
* McpOidcIntegration#resolve} has already succeeded once this boot, which proves those
* types resolve fine).
*/
private static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> cls, boolean oidcActive,
String rolesClaimPath) {
try {
return McpOidcIntegration.compileToolPolicy(cls, oidcActive, rolesClaimPath);
} catch (NoClassDefFoundError e) {
return null;
}
}
private static <T> T instantiate(Class<T> cls) {
try {
Constructor<T> ctor = cls.getDeclaredConstructor();
@@ -2,18 +2,18 @@ package dev.relism.flash.ext.mcp;
import java.util.List;
/** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */
/** RFC 9728 OAuth 2.0 Protected Resource Metadata. */
final class McpResourceMetadata {
private McpResourceMetadata() {}
/** {@code scopesSupported} is optional per RFC 9728 — omitted from the document if empty. */
static String build(String resourceIdentifier, String authorizationServerIssuer, List<String> scopesSupported) {
/** {@code scopesSupported} is optional — omitted when empty. */
static String build(String resource, List<String> authorizationServers, List<String> scopesSupported) {
return McpJson.buildString(gen -> {
gen.writeStartObject();
gen.writeStringField("resource", resourceIdentifier);
gen.writeStringField("resource", resource);
gen.writeArrayFieldStart("authorization_servers");
gen.writeString(authorizationServerIssuer);
for (String issuer : authorizationServers) gen.writeString(issuer);
gen.writeEndArray();
if (!scopesSupported.isEmpty()) {
gen.writeArrayFieldStart("scopes_supported");
@@ -1,17 +1,11 @@
package dev.relism.flash.ext.mcp;
/**
* OAuth2 requirement policy for the MCP endpoint, resolved against whether
* {@code flash-ext-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
*/
/** Whether the MCP endpoint requires an authenticated caller. */
public enum McpSecurity {
/** Fail fast at boot if {@code flash-ext-oidc} is not installed — never expose an unprotected MCP endpoint. */
/** The default: every call is authenticated by {@code flash-ext-security-core}, which must be installed. */
REQUIRED,
/** Protect the endpoint if {@code flash-ext-oidc} is installed; otherwise run unprotected and log a warning. */
AUTO,
/** Never protect the endpoint, even if {@code flash-ext-oidc} is installed elsewhere in the app. */
/** A public endpoint. Tools declaring security annotations fail the boot. */
NONE
}
@@ -19,7 +19,7 @@ final class McpTransportGuards {
* allowed through only a <em>present but disallowed</em> value is rejected.
*
* <p>If {@code allowedOrigins} is empty, validation is skipped and a boot-time warning is
* logged same graceful-degradation shape as {@link McpSecurity#AUTO}.
* logged.
*/
static Middleware originGuard(List<String> allowedOrigins) {
if (allowedOrigins.isEmpty()) {
@@ -38,7 +38,7 @@ final class McpTransportGuards {
/**
* Safety net around the whole MCP route: translates {@link HttpException} (thrown by
* {@link #originGuard} or by {@code flash-ext-oidc}'s middleware) into a proper HTTP status
* {@link #originGuard} or by {@code flash-ext-security-core}) into a proper HTTP status
* directly, instead of relying on the app's global exception handler which defaults to a
* generic 500 for every exception type unless the app owner overrides it (see
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint
@@ -1,109 +0,0 @@
package dev.relism.flash.ext.mcp;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Minimal, self-contained fake OIDC provider for tests: real discovery document, real JWKS
* endpoint, real RS256-signed tokens no network dependency beyond localhost, no mocking
* framework. Exercises {@code flash-ext-oidc}'s actual discovery + JWKS + JWT validation path.
*/
final class FakeOidcProvider implements AutoCloseable {
private final HttpServer server;
private final String issuer;
private final RSAKey rsaKey;
FakeOidcProvider() throws Exception {
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
gen.initialize(2048);
KeyPair kp = gen.generateKeyPair();
this.rsaKey = new RSAKey.Builder((RSAPublicKey) kp.getPublic())
.privateKey((RSAPrivateKey) kp.getPrivate())
.keyUse(KeyUse.SIGNATURE)
.algorithm(JWSAlgorithm.RS256)
.keyID(UUID.randomUUID().toString())
.build();
this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
this.issuer = "http://127.0.0.1:" + server.getAddress().getPort();
server.createContext("/.well-known/openid-configuration", ex -> respond(ex, discoveryDocument()));
server.createContext("/jwks", ex -> respond(ex, new JWKSet(rsaKey.toPublicJWK()).toJSONObject().toString()));
server.setExecutor(null);
server.start();
}
String issuer() { return issuer; }
/** Mints a valid RS256 access token — bearer-validation only, no full authorization-code round-trip needed. */
String signToken(String subject, String audience) {
return signToken(subject, audience, null, NO_ROLES);
}
/**
* Same as {@link #signToken(String, String)}, plus a {@code scope} claim (space-delimited,
* matching {@link dev.relism.flash.ext.oidc.OidcUser#hasScope}'s default claim path) and a
* Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default
* {@code rolesClaimPath}) when {@code roles} is non-empty.
*/
String signToken(String subject, String audience, String scope, String... roles) {
try {
JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
.issuer(issuer)
.subject(subject)
.audience(audience)
.issueTime(Date.from(Instant.now()))
.expirationTime(Date.from(Instant.now().plusSeconds(300)));
if (scope != null) builder.claim("scope", scope);
if (roles.length > 0) builder.claim("realm_access", Map.of("roles", List.of(roles)));
SignedJWT jwt = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), builder.build());
jwt.sign(new RSASSASigner(rsaKey));
return jwt.serialize();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private static final String[] NO_ROLES = new String[0];
private String discoveryDocument() {
return "{"
+ "\"issuer\":\"" + issuer + "\","
+ "\"authorization_endpoint\":\"" + issuer + "/auth\","
+ "\"token_endpoint\":\"" + issuer + "/token\","
+ "\"jwks_uri\":\"" + issuer + "/jwks\""
+ "}";
}
private static void respond(com.sun.net.httpserver.HttpExchange ex, String body) throws java.io.IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().add("Content-Type", "application/json");
ex.sendResponseHeaders(200, bytes.length);
try (OutputStream os = ex.getResponseBody()) { os.write(bytes); }
}
@Override
public void close() { server.stop(0); }
}
@@ -1,150 +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 org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
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 FlashApp app;
private FakeOidcProvider provider;
@AfterEach
void tearDown() {
if (app != null) app.stop();
if (provider != null) provider.close();
}
@Test
void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception {
int port = bootSecuredApp(SECURED_TOOLS);
String resourceId = "http://127.0.0.1:" + port + "/mcp";
String noRole = provider.signToken("user-1", resourceId, null);
HttpResponse<String> denied = callTool(port, "admin_only", noRole);
assertEquals(200, denied.statusCode());
assertTrue(denied.body().contains("\"isError\":true"), denied.body());
assertTrue(denied.body().contains("missing required role"), denied.body());
String withRole = provider.signToken("user-1", resourceId, null, "admin");
HttpResponse<String> allowed = callTool(port, "admin_only", withRole);
assertEquals(200, allowed.statusCode());
assertTrue(allowed.body().contains("\"isError\":false"), allowed.body());
assertTrue(allowed.body().contains("ok"), allowed.body());
}
@Test
void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception {
int port = bootSecuredApp(SECURED_TOOLS);
String resourceId = "http://127.0.0.1:" + port + "/mcp";
String noScope = provider.signToken("user-1", resourceId, "read");
HttpResponse<String> denied = callTool(port, "write_only", noScope);
assertEquals(200, denied.statusCode());
assertTrue(denied.body().contains("\"isError\":true"), denied.body());
assertTrue(denied.body().contains("missing required scope"), denied.body());
String withScope = provider.signToken("user-1", resourceId, "read write");
HttpResponse<String> allowed = callTool(port, "write_only", withScope);
assertEquals(200, allowed.statusCode());
assertTrue(allowed.body().contains("\"isError\":false"), allowed.body());
assertTrue(allowed.body().contains("written"), allowed.body());
}
@Test
void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception {
int port = bootSecuredApp(SECURED_TOOLS);
String resourceId = "http://127.0.0.1:" + port + "/mcp";
String plain = provider.signToken("user-1", resourceId, null);
HttpResponse<String> resp = callTool(port, "open", plain);
assertEquals(200, resp.statusCode());
assertTrue(resp.body().contains("\"isError\":false"), resp.body());
assertTrue(resp.body().contains("open"), resp.body());
}
@Test
void toolAnnotated_butSecurityNone_failsAtBoot() throws Exception {
provider = new FakeOidcProvider();
int port = freePort();
app = FlashApp.create(port);
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.NONE)
.build()));
IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start());
assertTrue(e.getMessage().contains("no active OAuth2 protection"), e.getMessage());
}
@Test
void bareAuthenticated_hasNoEffect_failsAtBoot() throws Exception {
provider = new FakeOidcProvider();
int port = freePort();
app = FlashApp.create(port);
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(AUTHENTICATED_ONLY_TOOLS)
.security(McpSecurity.REQUIRED)
.build()));
IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start());
assertTrue(e.getMessage().contains("no effect"), e.getMessage());
}
// Helpers
private int bootSecuredApp(String toolsPackage) throws Exception {
provider = new FakeOidcProvider();
int port = freePort();
app = FlashApp.create(port);
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(McpSecurity.REQUIRED)
.build()));
app.start();
return port;
}
private static HttpResponse<String> callTool(int port, String toolName, String token) throws Exception {
String body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"" + toolName + "\"}}";
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/mcp"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("Authorization", "Bearer " + token)
.POST(HttpRequest.BodyPublishers.ofString(body));
return HttpClient.newHttpClient().send(req.build(), HttpResponse.BodyHandlers.ofString());
}
private static int freePort() throws Exception {
try (ServerSocket s = new ServerSocket(0)) {
return s.getLocalPort();
}
}
}
@@ -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);
}
}
@@ -2,16 +2,10 @@ package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.extension.FlashApp;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -22,34 +16,15 @@ class McpExtensionIntegrationTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private FlashApp app;
private String mcpUrl;
private HttpClient client;
@BeforeEach
void setUp() throws Exception {
int port;
try (ServerSocket s = new ServerSocket(0)) {
port = s.getLocalPort();
}
mcpUrl = "http://127.0.0.1:" + port + "/mcp";
client = HttpClient.newHttpClient();
McpConfig config = McpConfig.builder("test-server")
// Every test here is a stateless JSON-RPC call against the same server, so one boot for
// the class rather than one per test.
@RegisterExtension
static FlashTest mcp = FlashTest.of(app -> app.install(new McpExtension(
McpConfig.builder("test-server")
.version("9.9.9")
.toolsPackage("dev.relism.flash.ext.mcp.fixtures")
.security(McpSecurity.NONE)
.build();
app = FlashApp.create(port);
app.install(new McpExtension(config));
app.start();
}
@AfterEach
void tearDown() {
if (app != null) app.stop();
}
.build())));
@Test
void initialize_returnsProtocolVersionCapabilitiesAndServerInfo() throws Exception {
@@ -104,17 +79,13 @@ class McpExtensionIntegrationTest {
}
@Test
void notification_returns202WithEmptyBody() throws Exception {
String body = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}";
HttpResponse<String> resp = post(body);
assertEquals(202, resp.statusCode());
void notification_returns202WithEmptyBody() {
post("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}").expectStatus(202);
}
@Test
void malformedJson_returns400ParseError() throws Exception {
HttpResponse<String> resp = post("not json");
assertEquals(400, resp.statusCode());
JsonNode json = MAPPER.readTree(resp.body());
JsonNode json = MAPPER.readTree(post("not json").expectStatus(400).body());
assertEquals(-32700, json.get("error").get("code").asInt());
}
@@ -128,16 +99,10 @@ class McpExtensionIntegrationTest {
private JsonNode call(int id, String method, String paramsJson) throws Exception {
String body = "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + paramsJson + "}";
HttpResponse<String> resp = post(body);
assertEquals(200, resp.statusCode());
return MAPPER.readTree(resp.body());
return MAPPER.readTree(post(body).expectStatus(200).body());
}
private HttpResponse<String> post(String body) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(mcpUrl))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
return client.send(req, HttpResponse.BodyHandlers.ofString());
private FlashResponse post(String body) {
return mcp.request().json(body).post("/mcp");
}
}
@@ -1,194 +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 org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.
*/
class McpExtensionSecurityTest {
private static final String TOOLS_PACKAGE = "dev.relism.flash.ext.mcp.fixtures";
private FlashApp app;
private FakeOidcProvider provider;
@AfterEach
void tearDown() {
if (app != null) app.stop();
if (provider != null) provider.close();
}
@Test
void required_withoutOidc_throwsAtBoot() throws Exception {
int port = freePort();
app = FlashApp.create(port);
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED)
.build()));
assertThrows(IllegalStateException.class, () -> app.start());
}
@Test
void auto_withoutOidc_degradesToPublic() throws Exception {
int port = freePort();
app = FlashApp.create(port);
app.install(new McpExtension(McpConfig.builder("auto-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.AUTO)
.build()));
app.start();
HttpResponse<String> resp = post(port, initializeBody(), null);
assertEquals(200, resp.statusCode());
}
@Test
void required_withOidc_rejectsMissingToken() throws Exception {
int port = bootSecuredApp(null);
HttpResponse<String> resp = post(port, initializeBody(), null);
assertEquals(401, resp.statusCode());
}
@Test
void required_withOidc_rejectsWrongAudience() throws Exception {
int port = bootSecuredApp("https://mcp.example.com/mcp");
String token = provider.signToken("user-1", "https://someone-else.example.com/resource");
HttpResponse<String> resp = post(port, initializeBody(), token);
assertEquals(403, resp.statusCode());
}
@Test
void required_withOidc_acceptsValidAudience() throws Exception {
String resourceId = "https://mcp.example.com/mcp";
int port = bootSecuredApp(resourceId);
String token = provider.signToken("user-1", resourceId);
HttpResponse<String> resp = post(port, initializeBody(), token);
assertEquals(200, resp.statusCode());
assertTrue(resp.body().contains("\"protocolVersion\""));
}
@Test
void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception {
int port = bootSecuredApp(null);
String derivedResourceId = "http://127.0.0.1:" + port + "/mcp";
String matching = provider.signToken("user-1", derivedResourceId);
assertEquals(200, post(port, initializeBody(), matching).statusCode());
String mismatched = provider.signToken("user-1", "https://someone-else.example.com/resource");
assertEquals(403, post(port, initializeBody(), mismatched).statusCode());
}
@Test
void required_withOidc_missingToken_challengeIncludesResourceMetadata() throws Exception {
int port = bootSecuredApp(null);
HttpResponse<String> resp = post(port, initializeBody(), null);
assertEquals(401, resp.statusCode());
String challenge = resp.headers().firstValue("WWW-Authenticate").orElse("");
assertTrue(challenge.contains(
"resource_metadata=\"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp\""),
"WWW-Authenticate: " + challenge);
}
@Test
void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() throws Exception {
int port = bootSecuredApp(null);
HttpResponse<String> resp = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(
"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp")).GET().build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(200, resp.statusCode());
assertTrue(resp.body().contains("\"resource\":\"http://127.0.0.1:" + port + "/mcp\""), resp.body());
assertTrue(resp.body().contains("\"authorization_servers\":[\"" + provider.issuer() + "\"]"), resp.body());
assertTrue(!resp.body().contains("scopes_supported"), "scopes_supported must be omitted when unset: " + resp.body());
}
@Test
void scopesSupported_published_inProtectedResourceMetadata() throws Exception {
provider = new FakeOidcProvider();
int port = freePort();
app = FlashApp.create(port);
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED)
.scopesSupported("openid", "profile", "email")
.build()));
app.start();
HttpResponse<String> resp = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(
"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp")).GET().build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(200, resp.statusCode());
assertTrue(resp.body().contains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]"), resp.body());
}
// Helpers
private int bootSecuredApp(String resourceIdentifier) throws Exception {
provider = new FakeOidcProvider();
int port = freePort();
OidcConfig oidcConfig = OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback")
.build();
var mcpBuilder = McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED);
if (resourceIdentifier != null) mcpBuilder.resourceIdentifier(resourceIdentifier);
app = FlashApp.create(port);
app.install(new OidcExtension(oidcConfig));
app.install(new McpExtension(mcpBuilder.build()));
app.start();
return port;
}
private static String initializeBody() {
return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}";
}
private static int freePort() throws Exception {
try (ServerSocket s = new ServerSocket(0)) {
return s.getLocalPort();
}
}
private static HttpResponse<String> post(int port, String body, String bearerToken) throws Exception {
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/mcp"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body));
if (bearerToken != null) req.header("Authorization", "Bearer " + bearerToken);
return HttpClient.newHttpClient().send(req.build(), HttpResponse.BodyHandlers.ofString());
}
}
@@ -16,7 +16,7 @@ class McpRegistryTest {
@Test
void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception {
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), false, "realm_access.roles");
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), null);
assertTrue(registry.hasTools());
assertTrue(registry.hasResources());
@@ -47,7 +47,7 @@ class McpRegistryTest {
@Test
void scan_emptyPackage_throwsInitializationException() {
assertThrows(InitializationException.class,
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), false, "realm_access.roles"));
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), null));
}
private static JsonNode findByField(JsonNode array, String field, String value) {
@@ -0,0 +1,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.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.oidc.RolesAllowed;
import dev.relism.flash.ext.security.RolesAllowed;
@Tool(name = "admin_only", description = "Only callable with the admin role")
@RolesAllowed("admin")
@RolesAllowed(value = "admin", on = "project")
public class AdminOnlyTool extends McpTool {
@Override
@@ -5,7 +5,7 @@ import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.oidc.ScopesAllowed;
import dev.relism.flash.ext.security.ScopesAllowed;
@Tool(name = "write_only", description = "Only callable with the write scope")
@ScopesAllowed("write")
-462
View File
@@ -1,462 +0,0 @@
# flash-ext-oidc
Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
Standards alignment focuses on OIDC Core + OAuth2 bearer APIs while preserving Flash's
hot-path model (middleware compiled at mount time, no heavy runtime work).
## What it provides
| Component | Description |
|---|---|
| `GET {prefix}/login` | Starts the OIDC flow: builds the authorization URL with PKCE + state, redirects |
| `GET {prefix}/callback` | Exchanges the code, validates the ID token, creates a session, redirects |
| `POST {prefix}/logout` | Invalidates the session, redirects to the provider's `end_session_endpoint` |
| `@Authenticated` | Annotation: protects a class-based handler (redirects browsers, 401 for API clients) |
| `@RolesAllowed(...)` | Annotation: protects with role check (OR semantics) |
| `@ScopesAllowed(...)` | Annotation: protects with scope check (`ALL` default, `ANY` optional) |
| `OidcMiddleware` | Programmatic middleware for lambda routes |
| `ClaimsHolder` / `OidcUser` | Thread-local user info accessible from any protected handler |
| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
## Dependencies
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
```
Transitive: `nimbus-jose-jwt`, `json-smart`.
Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically.
## Installation
```java
FlashApp.create(8080)
.install(new JacksonExtension())
.install(new OpenApiExtension(...)) // optional — enables Swagger security
.install(new OidcExtension(
OidcConfig.builder(
"https://idp.example.com",
"my-client", "my-secret", "/auth/callback")
.build()
))
.start();
```
Install order is irrelevant. The two-phase extension model guarantees all services
(including `OpenApiSecurityRegistry` from `flash-ext-openapi`) are registered before
any extension's routes phase runs.
### Keycloak shortcut
```java
OidcConfig.keycloak(
"https://keycloak.example.com", // server URL (no realm)
"myrealm", // realm
"my-client", "my-secret", // client credentials
"/auth/callback") // redirect URI (server-relative)
.https() // behind TLS
.build()
```
`keycloak()` pre-sets `rolesClaimPath("realm_access.roles")` and constructs the issuer as
`{serverUrl}/realms/{realm}`.
### Authelia / generic IdP
```java
OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/callback")
.rolesClaimPath("groups")
.build()
```
## OidcConfig reference
### Required fields
| Field | Description |
|---|---|
| `issuer` | Provider base URL — also used for OIDC discovery |
| `clientId` | OAuth2 client ID |
| `clientSecret` | OAuth2 client secret |
| `redirectUri` | Callback URI; server-relative paths (starting with `/`) are resolved at request time |
### Builder options
| Method | Default | Description |
|---|---|---|
| `.scopes("openid profile email")` | `"openid profile email"` | Space-separated requested scopes |
| `.routePrefix("/auth")` | `"/auth"` | Prefix for login/callback/logout routes |
| `.selfScheme("http")` | `"http"` | Scheme used when resolving server-relative redirect URIs |
| `.https()` | — | Shorthand for `.selfScheme("https")` |
| `.rolesClaimPath("realm_access.roles")` | `"realm_access.roles"` | Dot-path to the roles array in JWT claims |
| `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes |
| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation |
| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout |
| `.sessionStore(store)` | `InMemoryOidcSessionStore` | Custom session store (see below) |
| `.clientAuthMethod(ClientAuthMethod.POST)` | `POST` | `POST` = credentials in body; `BASIC` = `Authorization: Basic` |
| `.insecureTls()` | `false` | Disables TLS certificate verification — **development only** |
| `.schemeName("myscheme")` | derived from issuer | OpenAPI security scheme name |
### Environment variables (`OidcConfig.fromEnv()`)
```
OIDC_ISSUER required
OIDC_CLIENT_ID required
OIDC_CLIENT_SECRET required
OIDC_REDIRECT_URI required e.g. /auth/callback
OIDC_SCOPES default: openid profile email
OIDC_ROUTE_PREFIX default: /auth
OIDC_SELF_SCHEME default: http
OIDC_ROLES_CLAIM default: realm_access.roles
OIDC_SCOPE_CLAIMS default: scope,scp
OIDC_ALGORITHM default: RS256
OIDC_POST_LOGOUT_REDIRECT default: /
OIDC_CLIENT_AUTH_METHOD default: POST
```
## Protecting routes
### Class-based handlers (annotations)
```java
@Route(method = HttpMethod.GET, path = "/me")
@Authenticated
public class MePage extends JacksonHandler {
@Override
public Object handle(Request req, Response res) {
OidcUser u = ClaimsHolder.user();
return json(res, Map.of("sub", u.sub(), "email", u.email()));
}
}
@Route(method = HttpMethod.GET, path = "/admin")
@RolesAllowed("admin") // OR semantics: "admin" OR "superuser"
// @RolesAllowed({"admin", "superuser"})
public class AdminPage extends JacksonHandler { ... }
@Route(method = HttpMethod.POST, path = "/orders")
@ScopesAllowed("orders:write") // default = ALL semantics
public class CreateOrder extends JacksonHandler { ... }
@Route(method = HttpMethod.POST, path = "/payments")
@ScopesAllowed(value = {"payments:write", "payments:admin"}, match = ScopesAllowed.Match.ANY)
public class PayOrder extends JacksonHandler { ... }
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
@RolesAllowed("admin")
@ScopesAllowed("users:delete") // combined with AND semantics
public class DeleteUser extends JacksonHandler { ... }
```
The middleware is injected automatically by the annotation processor — no manual wiring needed.
Annotation composition rules:
- `@Authenticated` requires auth only
- `@RolesAllowed` implies authentication + role OR-check
- `@ScopesAllowed` implies authentication + scope check (`ALL`/`ANY`)
- combining `@RolesAllowed` + `@ScopesAllowed` uses AND semantics
- `@Authenticated(optional = true)` cannot be combined with role/scope constraints
### Lambda routes (manual middleware)
For lambda routes, pass the middleware as a varargs argument. Retrieve `OidcMiddleware`
from the context inside another extension's `routes()` phase, or after `start()`:
```java
OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
// Authentication only
app.get("/api/me", (req, res) -> {
OidcUser u = ClaimsHolder.user(); // never null here
return Map.of("sub", u.sub(), "email", u.email());
}, oidc.protect());
// Authentication + role check
app.delete("/api/admin/users/{id}", (req, res) -> {
OidcUser u = ClaimsHolder.user();
// ...
}, oidc.requireRole("admin"));
// Multiple roles (OR): passes if user holds any one of them
app.get("/api/reports", (req, res) -> { ... }, oidc.requireRole("admin", "reports-viewer"));
// Require all listed scopes
app.post("/api/orders", (req, res) -> { ... }, oidc.requireScopes("orders:write", "payments:write"));
// Require at least one listed scope
app.post("/api/payments", (req, res) -> { ... }, oidc.requireAnyScope("payments:write", "payments:admin"));
```
`oidc.protect()` / `oidc.requireRole(...)` / `oidc.requireScopes(...)` return a `Middleware` — a composable
`Handler → Handler` wrapper. Flash applies middleware right-to-left so the OIDC check
runs before your handler.
## Accessing the authenticated user
`ClaimsHolder` holds the JWT claims for the current request in a `ThreadLocal`.
It is populated by the OIDC middleware before your handler runs and cleared in the
`finally` block afterward. It is safe with virtual threads (each request gets its
own virtual thread, so `ThreadLocal` values are naturally isolated).
### OidcUser (preferred)
```java
OidcUser u = ClaimsHolder.user(); // never null inside a protected handler
String sub = u.sub(); // unique user ID
String email = u.email();
String username = u.username(); // preferred_username
String name = u.name(); // full display name
// Roles — pass the dot-path matching your provider's claim structure
List<String> roles = u.roles("realm_access.roles"); // Keycloak realm roles
List<String> clientRoles = u.roles("resource_access.my-client.roles"); // Keycloak client roles
List<String> groups = u.roles("groups"); // Authelia
boolean isAdmin = u.hasRole("realm_access.roles", "admin");
// Scopes (OIDC/OAuth2 generic): checks "scope" then "scp"
List<String> scopes = u.scopes();
boolean canWrite = u.hasScope("orders:write");
// Custom claim path resolution (for provider-specific payloads)
List<String> customScopes = u.scopes("scope,scp,permissions.scopes");
boolean canApprove = u.hasScope("permissions.scopes", "orders:approve");
// Arbitrary claim
String locale = (String) u.claim("locale");
Long exp = u.claim("exp", Long.class);
// Full raw map (escape hatch)
Map<String, Object> all = u.claims();
```
### Raw access (escape hatch)
```java
Map<String, Object> claims = ClaimsHolder.get();
String email = ClaimsHolder.claim("email");
```
## Performance
The middleware adds negligible overhead on the hot path for authenticated requests:
| Step | Cost |
|---|---|
| `Authorization` header check | `O(1)` map lookup |
| Cookie parse | `O(cookie_length)` single pass scan |
| Session lookup | `O(1)` `ConcurrentHashMap.get()` |
| Token expiry check | `O(1)` `Instant` comparison |
| `ClaimsHolder.set()` | `O(1)` `ThreadLocal.set()` |
No network calls, no cryptography, no JSON parsing on the happy path (valid session).
JWKS key fetching only happens for Bearer token validation and is cached + rate-limited by
Nimbus's `JWKSourceBuilder`. Silent token refresh only triggers when the access token expires.
Role/scope claim paths are compiled once during middleware construction (mount time), not per request.
## Authentication flow details
On each request the middleware resolves credentials in this order:
1. **Bearer token** (`Authorization: Bearer <jwt>`) — validated against JWKS.
2. **Session cookie** (`oidc_session`) — looked up in the session store; transparently
refreshed if the access token is expired (silent refresh via refresh token).
3. **No valid credentials**:
- Browser clients (no `Accept: application/json`) → redirect to `{prefix}/login?redirect={path}`
- API clients → `401 Unauthorized`
### API error semantics (RFC 6750)
For API clients (`Accept: application/json`) the middleware includes `WWW-Authenticate`:
- missing credentials: `Bearer realm="<schemeName>"`
- invalid bearer token: `Bearer realm="<schemeName>", error="invalid_token"`
- insufficient scopes: `Bearer realm="<schemeName>", error="insufficient_scope", scope="<required scopes>"`
This enables interoperable client-side handling and proper OAuth2 challenge semantics.
### Token validation (OIDC Core §3.1.3.7)
| Check | Access token | ID token |
|---|---|---|
| Signature (JWKS) | yes | yes |
| `iss` | yes | yes |
| `aud` = clientId | no (varies by provider) | yes |
| `exp`, `iat`, `sub` | yes | yes |
| `nonce` | — | yes |
JWKS keys are cached, rate-limited, and retried on cache-miss (handles key rotation).
### Claim merge strategy
At callback time the extension merges access token + ID token claims:
- Access token claims first (contains provider-specific data like `realm_access.roles`)
- ID token claims override (contains verified identity: `sub`, `email`, `name`, …)
This is provider-agnostic: authorization claims live in the AT per RFC 9068,
identity claims live in the IT per OIDC Core.
## Standards & compliance notes
This extension is designed to be compliant with the most relevant OIDC/OAuth2 RFCs:
- RFC 8414 (Authorization Server Metadata): discovery via `/.well-known/openid-configuration`
- OpenID Connect Core 1.0: Authorization Code flow + PKCE + `nonce` validation on ID token
- RFC 7636 (PKCE): S256 challenge/verifier flow
- RFC 6750 (Bearer Token Usage): `WWW-Authenticate` challenges with standard error codes
- RFC 9068 (JWT Profile for Access Tokens): JWT bearer access-token validation path
- RFC 7519 / RFC 7517 / RFC 7515 family: JWT/JWK/JWS validation via Nimbus + JWKS caching/rotation
Provider interoperability details:
- scope extraction supports both standard forms: `scope` (space-delimited string) and `scp` (list/string)
- roles remain configurable via `rolesClaimPath` (`realm_access.roles`, `groups`, etc.)
- scope claim fallback chain is configurable via `scopeClaimPaths`
## Testing scopes with Keycloak
Quick path to test `@ScopesAllowed` end-to-end:
1. **Create a client scope**
- Realm -> Client scopes -> Create
- Name: `orders:write` (or any scope name you want to enforce)
2. **Attach it to your client**
- Clients -> `<your-client>` -> Client scopes
- Add the scope as `Default` (always in token) or `Optional` (requested via `scope` param)
3. **Ensure scope mapper reaches the token**
- For most Keycloak setups this is automatic via built-in `microprofile-jwt`/scope mappers
- Verify the access token contains either `scope` string or `scp` list
4. **Request the scope in Flash config**
- Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"`
5. **Protect a handler**
- `@ScopesAllowed("orders:write")` on class-based handlers
- or `oidc.requireScopes("orders:write")` for lambda routes
6. **Verify behavior**
- token with scope -> 200
- token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope`
Useful token inspection flow while testing:
- Obtain a token from Keycloak
- Decode payload (`jwt.io` or local tool)
- check `scope` / `scp` claims
- call your protected endpoint and inspect status + `WWW-Authenticate`
## Session store
The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments.
For clustered deployments, implement `OidcSessionStore`:
```java
public interface OidcSessionStore {
void save(OidcSession session);
Optional<OidcSession> find(String sessionId);
void delete(String sessionId);
}
```
```java
OidcConfig.builder(...)
.sessionStore(new RedisOidcSessionStore(redisClient))
.build()
```
`OidcSession` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map).
## Logout
Add a logout button anywhere in your UI — a `<form>` is sufficient (no JavaScript needed):
```html
<form method="POST" action="/auth/logout">
<button type="submit">Logout</button>
</form>
```
The `POST {prefix}/logout` handler:
1. Reads the `oidc_session` cookie, looks up the session, retrieves the `id_token`.
2. Deletes the local session and clears the cookie (`Max-Age=0`).
3. If the provider has an `end_session_endpoint` (standard IdPs do), redirects there with
`?id_token_hint=<idToken>&post_logout_redirect_uri=<postLogoutRedirectUri>` — this logs
the user out of the IdP as well.
4. Otherwise redirects to `postLogoutRedirectUri` (default: `/`).
## Bearer token (API clients)
For API-to-API or SPA-to-API calls, pass a Bearer access token directly. The middleware
validates the JWT signature against JWKS and extracts the claims — no session involved:
```
Authorization: Bearer <access_token>
```
The token must be a JWT (opaque tokens are not supported). Claims are available via
`ClaimsHolder.user()` as usual.
## Multi-tenant
Multiple OIDC providers on one server — each `OidcExtension` instance is fully independent
(its own PKCE state store, session store, validator, and middleware):
```java
OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", "clientA", "secretA", "/a/auth/callback")
.routePrefix("/a/auth").schemeName("tenantA").build();
OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", "clientB", "secretB", "/b/auth/callback")
.routePrefix("/b/auth").schemeName("tenantB").build();
app.install(new OidcExtension(tenantA))
.install(new OidcExtension(tenantB));
```
To reference a specific tenant's middleware on lambda routes, keep the extension instances
and retrieve `OidcMiddleware` from context after `start()`:
```java
OidcExtension extA = new OidcExtension(tenantA);
OidcExtension extB = new OidcExtension(tenantB);
FlashApp app = FlashApp.create(8080)
.install(extA)
.install(extB)
.start()
.join(); // wait for bind
OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // last registered = tenantB
```
> **Note:** because both extensions register `OidcMiddleware.class` in the same context,
> only the last one wins under that key. For multi-tenant setups, use distinct context
> keys or provide middleware under a wrapper/alias type, or use lambda routes with explicit
> middleware captured from the extension instance before `install()`.
Class-based handlers annotated with `@Authenticated` / `@RolesAllowed` get the last
registered processor's middleware. For true multi-tenant class-based routing, install
tenant-specific annotation processors with different annotations.
## OpenAPI integration
If `flash-ext-openapi` is on the classpath and installed (order irrelevant),
the extension automatically:
- Adds a `components.securitySchemes` entry for the provider (OAuth2, authorizationCode flow)
- Adds `security` requirements to every operation whose handler carries `@Authenticated`
, `@RolesAllowed`, or `@ScopesAllowed`
No extra code needed. To customize the scheme name:
```java
OidcConfig.builder(...).schemeName("keycloak").build()
```
If `flash-ext-openapi` is absent the integration is silently skipped.
@@ -1,40 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a handler as requiring a valid JWT. Any bearer token that passes
* signature + expiry + issuer validation is accepted no role check is performed.
*
* <p>For role-based access use {@link RolesAllowed} instead (it implies authentication).
*
* <p>Set {@code optional = true} on public routes that personalise their response when
* the user happens to be logged in but should remain accessible to guests. The middleware
* will populate {@link ClaimsHolder} if credentials are present and silently skip it
* otherwise the request is never rejected.
*
* <pre>{@code
* // Hard auth redirects / 401 when unauthenticated:
* @Route(method = HttpMethod.GET, path = "/api/profile")
* @Authenticated
* public class GetProfile extends JacksonHandler { ... }
*
* // Soft auth guest-friendly, ClaimsHolder populated only when logged in:
* @Route(method = HttpMethod.GET, path = "/")
* @Authenticated(optional = true)
* public class HomePage extends HtmlHandler { ... }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Authenticated {
/**
* When {@code true} the middleware never rejects unauthenticated requests it only
* populates {@link ClaimsHolder} when valid credentials are present.
* Defaults to {@code false} (hard authentication required).
*/
boolean optional() default false;
}
@@ -1,71 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.Map;
/**
* Thread-local store for JWT claims, populated by the OIDC middleware before
* the handler runs and cleared in the {@code finally} block afterward.
*
* <p>Safe with virtual threads: each request gets its own virtual thread, so
* {@link ThreadLocal} values are naturally isolated per request.
*
* <pre>{@code
* // Inside any handler protected by @Authenticated or @RolesAllowed:
*
* // Preferred typed wrapper:
* OidcUser user = ClaimsHolder.user();
* String email = user.email();
* List<String> roles = user.roles("realm_access.roles");
*
* // Raw escape hatch:
* Map<String, Object> all = ClaimsHolder.get();
* }</pre>
*/
public final class ClaimsHolder {
private static final ThreadLocal<Map<String, Object>> HOLDER = new ThreadLocal<>();
private ClaimsHolder() {}
/** Called by the OIDC middleware after successful token validation. */
static void set(Map<String, Object> claims) {
HOLDER.set(claims);
}
/** Called by the OIDC middleware in the {@code finally} block. */
static void clear() {
HOLDER.remove();
}
/**
* Returns a type-safe {@link OidcUser} view of the current request's claims,
* or {@code null} if the route is not protected by OIDC middleware.
*
* <p>This is the preferred entry point for both lambda and class-based handlers.
*/
public static OidcUser user() {
Map<String, Object> claims = HOLDER.get();
return claims != null ? new OidcUser(claims) : null;
}
/**
* Returns the raw claims map for the current request, or {@code null} if
* the route is not protected by OIDC middleware.
*
* @see #user() for the preferred type-safe accessor
*/
public static Map<String, Object> get() {
return HOLDER.get();
}
/**
* Returns the value of a single claim as a String, or {@code null} if
* the claim is absent or the request is not authenticated.
*/
public static String claim(String key) {
Map<String, Object> claims = HOLDER.get();
if (claims == null) return null;
Object v = claims.get(key);
return v != null ? v.toString() : null;
}
}
@@ -1,18 +0,0 @@
package dev.relism.flash.ext.oidc;
/**
* OAuth2 client authentication method for the token endpoint (RFC 6749 §2.3).
*
* <ul>
* <li>{@link #POST} credentials sent as {@code client_id} / {@code client_secret}
* form fields (default; most providers).</li>
* <li>{@link #BASIC} credentials sent as an {@code Authorization: Basic} header;
* body contains only grant-specific parameters.</li>
* </ul>
*/
public enum ClientAuthMethod {
/** {@code client_secret_post} — credentials in the request body. */
POST,
/** {@code client_secret_basic} — credentials in the {@code Authorization} header. */
BASIC
}
@@ -1,50 +0,0 @@
package dev.relism.flash.ext.oidc;
import net.minidev.json.JSONValue;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
/**
* Fetches and parses the OIDC provider discovery document at
* {@code {issuer}/.well-known/openid-configuration}.
*/
final class DiscoveryClient {
private DiscoveryClient() {}
static OidcProviderMetadata fetch(String issuer, HttpClient http) throws Exception {
String url = issuer.endsWith("/")
? issuer + ".well-known/openid-configuration"
: issuer + "/.well-known/openid-configuration";
HttpResponse<String> resp = http.send(
HttpRequest.newBuilder().uri(URI.create(url)).GET().build(),
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200)
throw new IllegalStateException(
"OIDC discovery failed [" + resp.statusCode() + "]: " + url);
@SuppressWarnings("unchecked")
Map<String, Object> doc = (Map<String, Object>) JSONValue.parse(resp.body());
return new OidcProviderMetadata(
require(doc, "authorization_endpoint"),
require(doc, "token_endpoint"),
(String) doc.get("userinfo_endpoint"), // optional
require(doc, "jwks_uri"),
(String) doc.get("end_session_endpoint") // optional
);
}
private static String require(Map<String, Object> doc, String key) {
Object v = doc.get(key);
if (v == null) throw new IllegalStateException(
"Discovery doc missing required field: " + key);
return v.toString();
}
}
@@ -1,20 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe in-memory {@link OidcSessionStore}.
*
* <p>Sessions are lost on restart and not shared across instances. For
* production deployments with multiple nodes or restart-persistence requirements,
* supply a custom implementation via {@link OidcConfig.Builder#sessionStore}.
*/
public final class InMemoryOidcSessionStore implements OidcSessionStore {
private final ConcurrentHashMap<String, OidcSession> store = new ConcurrentHashMap<>();
@Override public void save(OidcSession s) { store.put(s.id(), s); }
@Override public Optional<OidcSession> find(String id) { return Optional.ofNullable(store.get(id)); }
@Override public void delete(String id) { store.remove(id); }
}
@@ -1,38 +0,0 @@
package dev.relism.flash.ext.oidc;
import net.minidev.json.JSONValue;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
/**
* Low-level JWT payload extraction no signature or expiry validation.
*
* <p>Use only for tokens received directly from the provider over a trusted TLS
* connection (e.g. {@code id_token} from the token endpoint). Bearer tokens on
* incoming requests must go through {@link JwtValidator#validate(String)} instead.
*/
final class JwtUtils {
private JwtUtils() {}
/**
* Base64URL-decodes the JWT payload and returns the claims as a map.
* Signature, expiry, and issuer are NOT checked.
*/
@SuppressWarnings("unchecked")
static Map<String, Object> parseClaims(String jwt) {
String[] parts = jwt.split("\\.");
if (parts.length < 2) throw new IllegalArgumentException("Malformed JWT: " + jwt);
// Pad to a multiple of 4 for the standard decoder
String padded = parts[1];
switch (padded.length() % 4) {
case 2 -> padded += "==";
case 3 -> padded += "=";
}
byte[] payload = Base64.getUrlDecoder().decode(padded);
return (Map<String, Object>) JSONValue.parse(
new String(payload, StandardCharsets.UTF_8));
}
}
@@ -1,184 +0,0 @@
package dev.relism.flash.ext.oidc;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSKeySelector;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.Resource;
import com.nimbusds.jose.util.ResourceRetriever;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import dev.relism.flash.exceptions.HttpException;
import java.io.IOException;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
import java.util.Set;
/**
* Validates JWTs against a remote JWKS endpoint using Nimbus JOSE+JWT.
*
* <p>Two validation modes:
* <ul>
* <li>{@link #validate(String)} access token bearer validation per request (hot path).
* Checks signature, {@code iss}, {@code exp}, {@code iat}, {@code sub}.
* Throws {@link HttpException} 401 so the middleware can short-circuit.</li>
* <li>{@link #validateIdToken(String, String)} ID token validation at callback time.
* Checks signature, {@code iss}, {@code aud} == clientId, {@code exp}, {@code iat},
* {@code sub}, and {@code nonce} (if provided).
* Throws {@link OidcValidationException} (not 401 it is a provider/protocol error).</li>
* </ul>
*
* <p>JWKS handling: the shared {@link JWKSource} uses caching + rate-limiting + automatic
* retry-on-key-miss (key rotation). Both processors share the same source one JWKS
* fetch serves both token types.
*/
public class JwtValidator {
private final JWKSource<SecurityContext> jwkSource;
private final ConfigurableJWTProcessor<SecurityContext> accessTokenProcessor;
private final ConfigurableJWTProcessor<SecurityContext> idTokenProcessor;
private final String algorithm;
/**
* @param jwksUri JWKS endpoint URI
* @param issuer Expected {@code iss} claim
* @param clientId OAuth2 client ID used as expected {@code aud} in ID tokens
* @param algorithm JWS algorithm (e.g. {@code "RS256"})
* @param http Shared {@link HttpClient} used for all JWKS fetches already configured
* with the correct TLS policy (trust-all or default trust store).
*/
public JwtValidator(String jwksUri, String issuer, String clientId,
String algorithm, HttpClient http) {
try {
// Use the caller-supplied HttpClient for JWKS retrieval so that TLS policy
// (insecureTls / custom trust store) is applied consistently everywhere.
this.jwkSource = JWKSourceBuilder
.create(new URL(jwksUri), httpRetriever(http))
.cache(true)
.rateLimited(true)
.retrying(true)
.build();
} catch (Exception e) {
throw new IllegalStateException("Failed to init JWKS source: " + jwksUri, e);
}
this.algorithm = algorithm;
this.accessTokenProcessor = buildAccessTokenProcessor(jwkSource, issuer, algorithm);
this.idTokenProcessor = buildIdTokenProcessor(jwkSource, issuer, clientId, algorithm);
}
// -- Public API -----------------------------------------------------------
/**
* Validates a JWT access token (bearer on incoming request).
* Returns claims on success; throws {@link HttpException} 401 on any failure.
*/
public Map<String, Object> validate(String token) {
if (!isJwt(token)) throw HttpException.unauthorized(); // opaque token can't validate
try {
return accessTokenProcessor.process(token, null).getClaims();
} catch (Exception e) {
throw HttpException.unauthorized();
}
}
/**
* Validates an ID token received directly from the token endpoint.
*
* <p>Checks: signature (JWKS), {@code iss}, {@code aud} == clientId,
* {@code exp}, {@code iat}, {@code sub}, and {@code nonce} if provided.
*
* @param idToken Raw ID token string
* @param nonce Nonce sent in the authorization request; {@code null} to skip check
* @throws OidcValidationException on any validation failure
*/
public Map<String, Object> validateIdToken(String idToken, String nonce) {
try {
Map<String, Object> claims = idTokenProcessor.process(idToken, null).getClaims();
if (nonce != null && !nonce.equals(claims.get("nonce")))
throw new OidcValidationException("ID token nonce mismatch", null);
return claims;
} catch (OidcValidationException e) {
throw e;
} catch (Exception e) {
throw new OidcValidationException("ID token validation failed: " + e.getMessage(), e);
}
}
/**
* Returns {@code true} if {@code token} is a signed JWT (three dot-separated Base64URL parts).
* Used to detect opaque access tokens before attempting JWKS validation.
*/
public static boolean isJwt(String token) {
if (token == null || token.isBlank()) return false;
int dots = 0;
for (int i = 0; i < token.length(); i++) if (token.charAt(i) == '.') dots++;
return dots == 2;
}
// -- Processors -----------------------------------------------------------
private static ConfigurableJWTProcessor<SecurityContext> buildAccessTokenProcessor(
JWKSource<SecurityContext> src, String issuer, String algorithm) {
ConfigurableJWTProcessor<SecurityContext> p = new DefaultJWTProcessor<>();
p.setJWSKeySelector(keySelector(src, algorithm));
// iss required; aud not enforced on ATs (varies by provider)
if (issuer != null && !issuer.isBlank()) {
p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
new JWTClaimsSet.Builder().issuer(issuer).build(),
Set.of("sub", "iat", "exp")));
}
return p;
}
private static ConfigurableJWTProcessor<SecurityContext> buildIdTokenProcessor(
JWKSource<SecurityContext> src, String issuer, String clientId, String algorithm) {
ConfigurableJWTProcessor<SecurityContext> p = new DefaultJWTProcessor<>();
p.setJWSKeySelector(keySelector(src, algorithm));
// iss + aud = clientId strictly required (OIDC Core §3.1.3.7)
JWTClaimsSet.Builder required = new JWTClaimsSet.Builder();
if (issuer != null) required.issuer(issuer);
if (clientId != null) required.audience(clientId);
p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
required.build(), Set.of("sub", "iat", "exp")));
return p;
}
private static JWSKeySelector<SecurityContext> keySelector(
JWKSource<SecurityContext> src, String algorithm) {
return new JWSVerificationKeySelector<>(JWSAlgorithm.parse(algorithm), src);
}
/**
* Wraps a {@link HttpClient} as a Nimbus {@link ResourceRetriever}.
* The client already carries the correct TLS policy (trust-all or default),
* so JWKS fetches honour the same SSL configuration as discovery and token requests.
*/
private static ResourceRetriever httpRetriever(HttpClient http) {
return url -> {
try {
HttpResponse<String> resp = http.send(
HttpRequest.newBuilder().uri(url.toURI()).GET().build(),
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200)
throw new IOException("JWKS fetch failed [" + resp.statusCode() + "]: " + url);
String contentType = resp.headers()
.firstValue("Content-Type").orElse("application/json");
return new Resource(resp.body(), contentType);
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("JWKS retrieval error: " + e.getMessage(), e);
}
};
}
}
@@ -1,98 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.LinkedHashSet;
import java.util.List;
/**
* Compiled authorization policy derived from handler annotations at mount time.
* Immutable and allocation-free on the request hot path.
*/
final class OidcAuthPolicy {
private static final String[] EMPTY = new String[0];
private static final OidcAuthPolicy AUTH_REQUIRED = new OidcAuthPolicy(
false, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
private static final OidcAuthPolicy AUTH_OPTIONAL = new OidcAuthPolicy(
true, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
private final boolean optionalAuth;
private final String[] requiredRoles;
private final String[] requiredScopes;
private final ScopesAllowed.Match scopeMatch;
private OidcAuthPolicy(boolean optionalAuth,
String[] requiredRoles,
String[] requiredScopes,
ScopesAllowed.Match scopeMatch) {
this.optionalAuth = optionalAuth;
this.requiredRoles = requiredRoles;
this.requiredScopes = requiredScopes;
this.scopeMatch = scopeMatch;
}
static OidcAuthPolicy authenticated() { return AUTH_REQUIRED; }
static OidcAuthPolicy optional() { return AUTH_OPTIONAL; }
static OidcAuthPolicy rolesAny(String... roles) {
return new OidcAuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL);
}
static OidcAuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) {
return new OidcAuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match);
}
static OidcAuthPolicy compileFromAnnotations(Class<?> handlerClass) {
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
if (auth == null && roles == null && scopes == null) return null;
boolean optionalAuth = auth != null && auth.optional();
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : EMPTY;
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : EMPTY;
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
if (optionalAuth && (requiredRoles.length > 0 || requiredScopes.length > 0)) {
throw new IllegalStateException("@Authenticated(optional = true) cannot be combined with @RolesAllowed/@ScopesAllowed on "
+ handlerClass.getName());
}
return new OidcAuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
}
static List<String> openApiScopesFor(Class<?> handlerClass) {
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
if (auth == null && roles == null && scopes == null) return null;
if (scopes == null) return List.of();
return List.of(normalizeRequired("ScopesAllowed", scopes.value()));
}
boolean optionalAuth() { return optionalAuth; }
String[] requiredRoles() { return requiredRoles; }
String[] requiredScopes() { return requiredScopes; }
ScopesAllowed.Match scopeMatch() { return scopeMatch; }
private static String[] normalizeRequired(String annotation, String[] values) {
if (values == null || values.length == 0)
throw new IllegalStateException("@" + annotation + " requires at least one value");
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
for (String raw : values) {
if (raw == null) continue;
String trimmed = raw.trim();
if (!trimmed.isEmpty()) normalized.add(trimmed);
}
if (normalized.isEmpty())
throw new IllegalStateException("@" + annotation + " requires at least one non-empty value");
return normalized.toArray(String[]::new);
}
}
@@ -1,261 +0,0 @@
package dev.relism.flash.ext.oidc;
/**
* Full OIDC client configuration. Build via
* {@link #builder(String, String, String, String)} or {@link #fromEnv()}.
*
* <p>Required fields: {@code issuer}, {@code clientId}, {@code clientSecret},
* {@code redirectUri}. Everything else has a sensible default.
*
* <p>If {@code redirectUri} starts with {@code /} it is treated as server-relative:
* the absolute URL is resolved at request time using {@link #selfScheme()} and the
* incoming {@code Host} header. Use {@link Builder#https()} when behind TLS.
*
* <pre>{@code
* // Keycloak
* OidcConfig.builder(
* "https://keycloak.example.com/realms/myrealm",
* "my-app", "secret", "/auth/callback")
* .rolesClaimPath("realm_access.roles") // Keycloak default
* .scopeClaimPaths("scope,scp") // default; supports many IdPs
* .build();
*
* // Authelia
* OidcConfig.builder(
* "https://auth.example.com",
* "my-app", "secret", "/auth/callback")
* .rolesClaimPath("groups")
* .scopeClaimPaths("scope,scp")
* .build();
*
* // Two tenants on one server
* OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", ..., "/tenantA/auth/callback")
* .routePrefix("/tenantA/auth").build();
* OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", ..., "/tenantB/auth/callback")
* .routePrefix("/tenantB/auth").build();
* app.install(new OidcExtension(tenantA))
* .install(new OidcExtension(tenantB));
* }</pre>
*/
public final class OidcConfig {
private final String issuer;
private final String clientId;
private final String clientSecret;
private final String redirectUri;
private final String scopes;
private final String routePrefix;
private final String selfScheme;
private final String rolesClaimPath;
private final String scopeClaimPaths;
private final String algorithm;
private final String postLogoutRedirectUri;
private final OidcSessionStore sessionStore;
private final boolean insecureTls;
private final ClientAuthMethod clientAuthMethod;
private final String schemeName;
private OidcConfig(Builder b) {
this.issuer = require(b.issuer, "issuer");
this.clientId = require(b.clientId, "clientId");
this.clientSecret = require(b.clientSecret, "clientSecret");
this.redirectUri = require(b.redirectUri, "redirectUri");
this.scopes = b.scopes;
this.routePrefix = b.routePrefix;
this.selfScheme = b.selfScheme;
this.rolesClaimPath = b.rolesClaimPath;
this.scopeClaimPaths = b.scopeClaimPaths;
this.algorithm = b.algorithm;
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
this.sessionStore = b.sessionStore != null ? b.sessionStore
: new InMemoryOidcSessionStore();
this.insecureTls = b.insecureTls;
this.clientAuthMethod = b.clientAuthMethod;
this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer);
}
// -- Getters --------------------------------------------------------------
public String issuer() { return issuer; }
public String clientId() { return clientId; }
public String clientSecret() { return clientSecret; }
public String redirectUri() { return redirectUri; }
public String scopes() { return scopes; }
public String routePrefix() { return routePrefix; }
public String selfScheme() { return selfScheme; }
public String rolesClaimPath() { return rolesClaimPath; }
/** Comma-separated claim paths used to read OAuth2 scopes (default: {@code "scope,scp"}). */
public String scopeClaimPaths() { return scopeClaimPaths; }
public String algorithm() { return algorithm; }
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
public OidcSessionStore sessionStore() { return sessionStore; }
/** If {@code true}, TLS certificate validation is skipped. <b>Never use in production.</b> */
public boolean insecureTls() { return insecureTls; }
public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; }
/** OpenAPI security scheme name (derived from issuer if not set explicitly). */
public String schemeName() { return schemeName; }
// -- Factory --------------------------------------------------------------
/**
* Reads configuration from environment variables:
* <pre>
* OIDC_ISSUER required
* OIDC_CLIENT_ID required
* OIDC_CLIENT_SECRET required
* OIDC_REDIRECT_URI required (e.g. /auth/callback)
* OIDC_SCOPES default: openid profile email
* OIDC_ROUTE_PREFIX default: /auth
* OIDC_SELF_SCHEME default: http
* OIDC_ROLES_CLAIM default: realm_access.roles
* OIDC_SCOPE_CLAIMS default: scope,scp
* OIDC_ALGORITHM default: RS256
* OIDC_POST_LOGOUT_REDIRECT default: /
* </pre>
*/
public static OidcConfig fromEnv() {
return builder(env("OIDC_ISSUER"), env("OIDC_CLIENT_ID"),
env("OIDC_CLIENT_SECRET"), env("OIDC_REDIRECT_URI"))
.scopes (envOr("OIDC_SCOPES", "openid profile email"))
.routePrefix (envOr("OIDC_ROUTE_PREFIX", "/auth"))
.selfScheme (envOr("OIDC_SELF_SCHEME", "http"))
.rolesClaimPath (envOr("OIDC_ROLES_CLAIM", "realm_access.roles"))
.scopeClaimPaths (envOr("OIDC_SCOPE_CLAIMS", "scope,scp"))
.algorithm (envOr("OIDC_ALGORITHM", "RS256"))
.postLogoutRedirectUri(envOr("OIDC_POST_LOGOUT_REDIRECT", "/"))
.clientAuthMethod(ClientAuthMethod.valueOf(
envOr("OIDC_CLIENT_AUTH_METHOD", "POST").toUpperCase()))
.build();
}
public static Builder builder(String issuer, String clientId,
String clientSecret, String redirectUri) {
return new Builder(issuer, clientId, clientSecret, redirectUri);
}
/**
* Convenience factory for Keycloak: constructs the issuer as
* {@code {serverUrl}/realms/{realm}} automatically.
*
* <pre>{@code
* OidcConfig.keycloak(
* "https://keycloak.example.com", "flashboard",
* "my-app", "secret", "/auth/callback")
* .https()
* .build();
* }</pre>
*/
public static Builder keycloak(String serverUrl, String realm,
String clientId, String clientSecret,
String redirectUri) {
String base = serverUrl.endsWith("/") ? serverUrl.substring(0, serverUrl.length() - 1) : serverUrl;
String issuer = base + "/realms/" + realm;
return new Builder(issuer, clientId, clientSecret, redirectUri)
.rolesClaimPath("realm_access.roles"); // Keycloak default
}
// -- Helpers --------------------------------------------------------------
private static String require(String v, String name) {
if (v == null || v.isBlank())
throw new IllegalArgumentException("OidcConfig: " + name + " is required");
return v;
}
private static String env(String key) {
String v = System.getenv(key);
if (v == null || v.isBlank())
throw new IllegalArgumentException("Missing required env var: " + key);
return v;
}
private static String envOr(String key, String def) {
String v = System.getenv(key);
return (v != null && !v.isBlank()) ? v : def;
}
// -- Builder --------------------------------------------------------------
public static final class Builder {
private final String issuer;
private final String clientId;
private final String clientSecret;
private final String redirectUri;
private String scopes = "openid profile email";
private String routePrefix = "/auth";
private String selfScheme = "http";
private String rolesClaimPath = "realm_access.roles";
private String scopeClaimPaths = "scope,scp";
private String algorithm = "RS256";
private String postLogoutRedirectUri = "/";
private OidcSessionStore sessionStore;
private boolean insecureTls = false;
private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
private String schemeName = null;
private Builder(String issuer, String clientId, String clientSecret, String redirectUri) {
this.issuer = issuer;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.redirectUri = redirectUri;
}
/** Override requested scopes (default: {@code openid profile email}). */
public Builder scopes(String scopes) { this.scopes = scopes; return this; }
/** Route prefix for login/callback/logout (default: {@code /auth}). */
public Builder routePrefix(String prefix) { this.routePrefix = prefix; return this; }
/** Scheme used when resolving self-relative redirect URIs (default: {@code http}). */
public Builder selfScheme(String scheme) { this.selfScheme = scheme; return this; }
/** Shorthand for {@code selfScheme("https")}. */
public Builder https() { return selfScheme("https"); }
/** Dot-separated path to the roles array in JWT claims (default: {@code realm_access.roles}). */
public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
/** Comma-separated claim paths used to resolve OAuth2 scopes (default: {@code scope,scp}). */
public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; }
/** JWS algorithm (default: {@code RS256}). */
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
/** Where to redirect after logout (default: {@code /}). */
public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
/** Custom session store (default: {@link InMemoryOidcSessionStore}). */
public Builder sessionStore(OidcSessionStore store) { this.sessionStore = store; return this; }
/**
* Disables TLS certificate verification for all HTTP calls made by this extension.
* <b>Only use in development with self-signed certificates never in production.</b>
*/
public Builder insecureTls() { this.insecureTls = true; return this; }
/** Token endpoint client authentication method (default: {@link ClientAuthMethod#POST}). */
public Builder clientAuthMethod(ClientAuthMethod method) { this.clientAuthMethod = method; return this; }
/** Override the OpenAPI security scheme name (default: derived from the issuer URI). */
public Builder schemeName(String name) { this.schemeName = name; return this; }
public OidcConfig build() { return new OidcConfig(this); }
}
/**
* Derives a short, human-readable scheme name from the issuer URI.
* Takes the last non-empty path segment; falls back to the host.
*
* <p>Examples:
* <ul>
* <li>{@code https://keycloak.dev.home/realms/flashboard} {@code "flashboard"}</li>
* <li>{@code https://auth.example.com} {@code "auth.example.com"}</li>
* </ul>
*/
private static String deriveScheme(String issuer) {
try {
java.net.URI uri = new java.net.URI(issuer);
String path = uri.getPath();
if (path != null && !path.isEmpty()) {
String[] parts = path.split("/");
for (int i = parts.length - 1; i >= 0; i--) {
if (!parts[i].isEmpty()) return parts[i];
}
}
return uri.getHost();
} catch (Exception e) {
return "oidc";
}
}
}
@@ -1,337 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.ext.openapi.OpenApiContributor;
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.models.Request;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.*;
/**
* Full OIDC Authorization Code + PKCE flow for Flash.
*
* <p>At {@link #provide}, the extension:
* <ol>
* <li>Fetches the provider discovery document fail-fast at startup.</li>
* <li>Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.</li>
* <li>Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
* and {@link ScopesAllowed}.</li>
* </ol>
*
* <p>At {@link #routes}, three routes are registered:
* <ul>
* <li>{@code GET {prefix}/login} builds the authorization URL and redirects.</li>
* <li>{@code GET {prefix}/callback} exchanges the code, creates a session, redirects.</li>
* <li>{@code POST {prefix}/logout} invalidates the session, redirects to provider
* end-session endpoint (if available) or to {@link OidcConfig#postLogoutRedirectUri()}.</li>
* </ul>
*
* <pre>{@code
* // Keycloak
* app.install(new OidcExtension(
* OidcConfig.builder(
* "https://keycloak.example.com/realms/myrealm",
* "my-app", "secret", "/auth/callback")
* .rolesClaimPath("realm_access.roles")
* .build()));
*
* // Two providers / tenants on one server
* app.install(new OidcExtension(tenantAConfig))
* .install(new OidcExtension(tenantBConfig));
* }</pre>
*/
public class OidcExtension implements FlashExtension {
private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.oidc.policy");
private final OidcConfig config;
// Initialized in provide(), used in routes() private to this extension instance.
private OidcProviderMetadata meta;
private OidcStateStore stateStore;
private TokenClient tokenClient;
private JwtValidator validator;
private OidcMiddleware oidcMw;
public OidcExtension(OidcConfig config) {
this.config = config;
}
// Phase 1: services
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
HttpClient http = buildHttpClient(config);
// Discover provider endpoints (blocking; fail fast at startup).
try {
meta = DiscoveryClient.fetch(config.issuer(), http);
} catch (Exception e) {
throw new IllegalStateException("OIDC discovery failed for issuer: " + config.issuer(), e);
}
validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http);
stateStore = new OidcStateStore();
tokenClient = new TokenClient(http, config);
oidcMw = new OidcMiddleware(validator, config, meta, tokenClient);
ctx.provide(OidcMiddleware.class, oidcMw);
ctx.provide(JwtValidator.class, validator);
ctx.addAnnotationProcessor(handlerClass -> {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
return policy != null ? List.of(MiddlewareNode.of(POLICY, oidcMw.policyMiddleware(policy))) : List.of();
});
ctx.onReady(() -> registerRoutes(app, ctx));
}
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
String prefix = config.routePrefix();
// GET {prefix}/login
// Builds the provider authorization URL with PKCE + state and redirects.
// Optional query param: ?redirect={relative-url} (default: /)
app.get(prefix + "/login", (req, res) -> {
String verifier = PkceUtils.generateVerifier();
String challenge = PkceUtils.computeChallenge(verifier);
String state = UUID.randomUUID().toString();
String nonce = UUID.randomUUID().toString();
String redirect = req.query("redirect");
if (redirect == null || !redirect.startsWith("/")) redirect = "/";
stateStore.put(state, redirect, verifier, nonce);
String authUrl = meta.authorizationEndpoint()
+ "?response_type=code"
+ "&client_id=" + enc(config.clientId())
+ "&redirect_uri=" + enc(absoluteRedirectUri(req))
+ "&scope=" + enc(config.scopes())
+ "&state=" + state
+ "&nonce=" + enc(nonce)
+ "&code_challenge=" + challenge
+ "&code_challenge_method=S256";
res.redirect(authUrl);
return null;
});
// GET {prefix}/callback
// Validates state, exchanges code for tokens, creates session, redirects.
app.get(prefix + "/callback", (req, res) -> {
String error = req.query("error");
if (error != null) {
res.status(400);
return "Authentication error: " + error
+ (req.query("error_description") != null
? "" + req.query("error_description") : "");
}
String code = req.query("code");
String state = req.query("state");
OidcStateStore.Entry entry = stateStore.consumeAndRemove(state).orElse(null);
if (entry == null) {
res.status(400);
return "Invalid or expired state parameter";
}
OidcTokenResponse tokens = tokenClient.exchangeCode(
meta.tokenEndpoint(), code, absoluteRedirectUri(req), entry.codeVerifier());
// Validate ID token: signature + iss + aud + exp + iat + sub + nonce (OIDC Core §3.1.3.7)
if (tokens.idToken() != null) {
try {
validator.validateIdToken(tokens.idToken(), entry.nonce());
} catch (OidcValidationException e) {
res.status(400);
return "ID token validation failed: " + e.getMessage();
}
}
Map<String, Object> claims = mergeClaims(tokens);
OidcSession session = new OidcSession(
UUID.randomUUID().toString(),
tokens.accessToken(), tokens.idToken(), tokens.refreshToken(),
Instant.now().plusSeconds(tokens.expiresIn()), claims);
config.sessionStore().save(session);
res.header("Set-Cookie", sessionCookie(session.id()))
.redirect(entry.originalUrl());
return null;
});
// POST {prefix}/logout
// Invalidates the local session and redirects to end_session_endpoint.
app.post(prefix + "/logout", (req, res) -> {
String sessionId = OidcMiddleware.cookieValue(req, "oidc_session");
String idTokenHint = null;
if (sessionId != null) {
OidcSession session = config.sessionStore().find(sessionId).orElse(null);
if (session != null) idTokenHint = session.idToken();
config.sessionStore().delete(sessionId);
}
String clearCookie = "oidc_session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax";
String location;
if (meta.endSessionEndpoint() != null) {
String postLogout = absoluteSelf(req, config.postLogoutRedirectUri());
StringBuilder url = new StringBuilder(meta.endSessionEndpoint())
.append("?post_logout_redirect_uri=").append(enc(postLogout));
if (idTokenHint != null)
url.append("&id_token_hint=").append(enc(idTokenHint));
location = url.toString();
} else {
location = config.postLogoutRedirectUri();
}
res.header("Set-Cookie", clearCookie).redirect(location);
return null;
});
// Register OpenAPI security scheme if flash-ext-openapi is on the classpath.
try {
OpenApiIntegration.register(ctx, config, meta);
} catch (NoClassDefFoundError ignored) {
// flash-ext-openapi not available OpenAPI integration disabled
}
}
// Helpers
/**
* Merges claims from both the access token and the ID token.
* ID token values win on conflict so that verified identity claims are authoritative.
*/
private static Map<String, Object> mergeClaims(OidcTokenResponse tokens) {
Map<String, Object> merged = new HashMap<>();
if (tokens.accessToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
if (tokens.idToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
return Map.copyOf(merged);
}
/**
* Builds an {@link HttpClient}. If {@link OidcConfig#insecureTls()} is set,
* installs a trust-all {@link SSLContext} that accepts any certificate.
* <b>Only safe for development with self-signed certificates.</b>
*/
private static HttpClient buildHttpClient(OidcConfig config) {
if (!config.insecureTls()) return HttpClient.newHttpClient();
try {
TrustManager[] trustAll = { new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
public void checkClientTrusted(X509Certificate[] c, String a) {}
public void checkServerTrusted(X509Certificate[] c, String a) {}
}};
SSLContext sslCtx = SSLContext.getInstance("TLS");
sslCtx.init(null, trustAll, new SecureRandom());
return HttpClient.newBuilder().sslContext(sslCtx).build();
} catch (Exception e) {
throw new IllegalStateException("Failed to create trust-all SSLContext", e);
}
}
private String absoluteRedirectUri(Request req) {
return absoluteSelf(req, config.redirectUri());
}
private String absoluteSelf(Request req, String uri) {
if (!uri.startsWith("/")) return uri;
return OidcMiddleware.selfOrigin(req, config.selfScheme()) + uri;
}
private static String enc(String v) {
return URLEncoder.encode(v, StandardCharsets.UTF_8);
}
private static String sessionCookie(String id) {
return "oidc_session=" + id + "; HttpOnly; Path=/; SameSite=Lax";
}
/**
* Loaded lazily so that {@code flash-ext-openapi} classes are only resolved at
* runtime when {@link OpenApiContributorRegistry} is actually on the classpath.
*/
private static final class OpenApiIntegration {
static void register(FlashContext ctx,
OidcConfig config, OidcProviderMetadata meta) {
ctx.find(OpenApiContributorRegistry.class)
.ifPresent(registry -> registry.add(new OpenApiContributor() {
@Override
public Map<String, Object> componentContributions() {
Map<String, String> scopesMap = new LinkedHashMap<>();
for (String s : config.scopes().split("\\s+")) {
if (!s.isBlank()) scopesMap.put(s, s);
}
Map<String, Object> flow = new LinkedHashMap<>();
flow.put("authorizationUrl", meta.authorizationEndpoint());
flow.put("tokenUrl", meta.tokenEndpoint());
flow.put("scopes", scopesMap);
Map<String, Object> scheme = new LinkedHashMap<>();
scheme.put("type", "oauth2");
scheme.put("flows", Map.of("authorizationCode", flow));
Map<String, Object> securitySchemes = new LinkedHashMap<>();
securitySchemes.put(config.schemeName(), scheme);
return Map.of("securitySchemes", securitySchemes);
}
@Override
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
OpenApiOperationContribution.Builder out =
OpenApiOperationContribution.builder();
List<String> operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
if (operationScopes != null) {
out.security(config.schemeName(), operationScopes);
}
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
if (policy == null || policy.optionalAuth()) return out.build();
out.response(401, OpenApiResponseContribution.of("Authentication required"));
String[] roles = policy.requiredRoles();
String[] scopes = policy.requiredScopes();
if (roles.length == 0 && scopes.length == 0) return out.build();
String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles);
String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes);
if (roleMessage != null && scopeMessage != null) {
out.response(403, OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage));
} else
out.response(403, OpenApiResponseContribution.of(Objects.requireNonNullElse(roleMessage, scopeMessage)));
return out.build();
}
}));
}
private static String roleRequiredMessage(String[] roles) {
if (roles.length == 1) return "\"" + roles[0] + "\" role required";
return "Roles \"" + String.join(", ", roles) + "\" are required";
}
private static String scopeRequiredMessage(String[] scopes) {
if (scopes.length == 1) return "\"" + scopes[0] + "\" scope required";
return "Scopes \"" + String.join(", ", scopes) + "\" are required";
}
}
}
@@ -1,550 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.Response;
import dev.relism.flash.models.Request;
import dev.relism.flash.routing.Middleware;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Request-level OIDC middleware. Exposed in the {@link FlashContext}
* for manual use on lambda routes; injected automatically for handlers annotated with
* {@link Authenticated}, {@link RolesAllowed} or {@link ScopesAllowed}.
*
* <p>Resolution order on each request:
* <ol>
* <li>{@code Authorization: Bearer ...} header validated via JWKS ({@link JwtValidator}).</li>
* <li>{@code oidc_session} cookie looked up in {@link OidcSessionStore}; transparently
* refreshed if the access token is expired.</li>
* <li>Browser clients (no {@code Accept: application/json}) redirect to
* {@code {routePrefix}/login?redirect={path}}.</li>
* <li>API clients 401.</li>
* </ol>
*
* <pre>{@code
* // Manual use on a lambda route:
* OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
* app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), oidc.protect());
* app.delete("/admin/users/{id}", handler, oidc.requireRole("admin"));
* }</pre>
*/
public class OidcMiddleware {
private static final String BEARER = "Bearer";
private final JwtValidator validator;
private final OidcConfig config;
private final OidcProviderMetadata meta;
private final TokenClient tokenClient;
private final String[] roleClaimPathParts;
private final String[][] scopeClaimPathParts;
OidcMiddleware(JwtValidator validator, OidcConfig config,
OidcProviderMetadata meta, TokenClient tokenClient) {
this.validator = validator;
this.config = config;
this.meta = meta;
this.tokenClient = tokenClient;
this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath());
this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths());
}
// -- Public API -----------------------------------------------------------
/** The single configured claim path used by every transport for role checks. */
public String rolesClaimPath() { return config.rolesClaimPath(); }
/**
* Validates the bearer token or session cookie. Browser clients are redirected
* to the login page on failure; API clients receive 401.
*/
public Middleware protect() {
return protect(null);
}
/**
* Like {@link #protect()}, but a 401 challenge also carries {@code resource_metadata}
* (RFC 9728 §5.1), resolved against this request's own scheme/host exactly like
* {@link OidcExtension}'s redirect URIs. {@code resourceMetadataPath} is an absolute path
* (e.g. {@code "/.well-known/oauth-protected-resource/mcp"}); pass {@code null} for plain
* challenges. Used by {@code flash-ext-mcp} to make its Protected Resource Metadata
* document discoverable straight from the {@code WWW-Authenticate} header, per the MCP
* Authorization spec.
*/
public Middleware protect(String resourceMetadataPath) {
return next -> (req, res) -> {
Map<String, Object> claims = resolve(req, res, resourceMetadataPath);
if (claims == null) return null; // redirect already written
ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/** OIDC issuer this middleware validates tokens against — the {@code iss} claim it enforces. */
public String issuer() { return config.issuer(); }
/** Scheme used to build this app's own absolute URLs — see {@link OidcConfig#selfScheme()}. */
public String selfScheme() { return config.selfScheme(); }
/**
* Silently populates {@link ClaimsHolder} if a valid bearer token or session cookie
* is present, but never rejects or redirects unauthenticated requests. Use this on
* public routes that want to personalise the response when the user happens to be
* logged in (e.g. showing a username on a landing page).
*
* <pre>{@code
* app.get("/", handler, oidc.optional());
* // Inside handler: ClaimsHolder.user() is non-null iff the user is logged in.
* }</pre>
*/
public Middleware optional() {
return next -> (req, res) -> {
Map<String, Object> claims = resolveQuiet(req);
if (claims != null) ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/**
* Compiled authorization policy path used by annotation-driven mounting.
* The policy is immutable and built once at boot.
*/
public Middleware authorize(OidcAuthPolicy policy) {
if (policy.optionalAuth()) return optional();
return next -> (req, res) -> {
Map<String, Object> claims = resolve(req, res);
if (claims == null) return null;
enforcePolicy(claims, policy, res);
ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/**
* Like {@link #protect()} but also enforces that the caller holds at least one
* of the given roles (OR semantics). Roles are extracted via
* {@link OidcConfig#rolesClaimPath()}.
*/
public Middleware requireRole(String... roles) {
return authorize(OidcAuthPolicy.rolesAny(roles));
}
/**
* Requires all listed scopes to be present in the token.
* Scopes are resolved from configured claim paths (default: {@code scope,scp}).
*/
public Middleware requireScopes(String... scopes) {
return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL));
}
/**
* Requires at least one of the listed scopes to be present in the token.
* Scopes are resolved from configured claim paths (default: {@code scope,scp}).
*/
public Middleware requireAnyScope(String... scopes) {
return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY));
}
// -- Package-private: AnnotationProcessor hooks ---------------------------
Middleware authenticatedMiddleware() { return protect(); }
Middleware optionalMiddleware() { return optional(); }
Middleware rolesMiddleware(String[] required) { return requireRole(required); }
Middleware scopesMiddleware(String[] required, ScopesAllowed.Match match) {
return authorize(OidcAuthPolicy.scopes(required, match));
}
Middleware policyMiddleware(OidcAuthPolicy policy) { return authorize(policy); }
// -- Internals ------------------------------------------------------------
/**
* Like {@link #resolve} but never redirects or throws returns {@code null} silently
* when no valid credentials are present. Used by {@link #optional()}.
*/
private Map<String, Object> resolveQuiet(Request req) {
String bearerToken = extractBearerToken(req.header("Authorization"));
if (bearerToken != null) {
try {
return validator.validate(bearerToken);
} catch (Exception ignored) {
return null;
}
}
String sessionId = cookieValue(req, "oidc_session");
if (sessionId != null) {
Optional<OidcSession> found = config.sessionStore().find(sessionId);
if (found.isPresent()) {
OidcSession session = found.get();
if (!session.isAccessTokenExpired())
return session.claims();
if (session.refreshToken() != null) {
try {
OidcSession refreshed = doRefresh(session);
config.sessionStore().save(refreshed);
return refreshed.claims();
} catch (Exception ignored) { }
}
config.sessionStore().delete(sessionId);
}
}
return null;
}
/**
* Returns claims on success, or {@code null} if a redirect was already written to
* {@code res}. Throws {@link HttpException} 401/403 for API clients.
*/
private Map<String, Object> resolve(Request req, Response res) {
return resolve(req, res, null);
}
private Map<String, Object> resolve(Request req, Response res, String resourceMetadataPath) {
// 1. Bearer token
String bearerToken = extractBearerToken(req.header("Authorization"));
if (bearerToken != null) {
try {
return validator.validate(bearerToken);
} catch (HttpException e) {
res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath));
throw e;
}
}
// 2. Session cookie
String sessionId = cookieValue(req, "oidc_session");
if (sessionId != null) {
Optional<OidcSession> found = config.sessionStore().find(sessionId);
if (found.isPresent()) {
OidcSession session = found.get();
if (!session.isAccessTokenExpired())
return session.claims();
// Access token expired try silent refresh
if (session.refreshToken() != null) {
try {
OidcSession refreshed = doRefresh(session);
config.sessionStore().save(refreshed);
return refreshed.claims();
} catch (Exception ignored) {
// Refresh failed fall through to re-authenticate
}
}
config.sessionStore().delete(sessionId);
}
}
// 3. No valid credentials
String accept = req.header("Accept");
if (accept != null && accept.contains("application/json")) {
res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath));
throw HttpException.unauthorized();
}
// Browser redirect to login, preserving the original URL in state
String loginUrl = config.routePrefix() + "/login?redirect="
+ URLEncoder.encode(req.path(), StandardCharsets.UTF_8);
res.redirect(loginUrl);
return null;
}
private OidcSession doRefresh(OidcSession old) throws Exception {
OidcTokenResponse tokens = tokenClient.refresh(
meta.tokenEndpoint(), old.refreshToken());
Map<String, Object> claims = mergeRefreshedClaims(tokens, old);
return new OidcSession(
old.id(),
tokens.accessToken(),
tokens.idToken() != null ? tokens.idToken() : old.idToken(),
tokens.refreshToken() != null ? tokens.refreshToken() : old.refreshToken(),
Instant.now().plusSeconds(tokens.expiresIn()),
claims
);
}
private void enforcePolicy(Map<String, Object> claims, OidcAuthPolicy policy, Response res) {
checkRoles(claims, policy.requiredRoles());
checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res);
}
private void checkRoles(Map<String, Object> claims, String[] required) {
if (required.length == 0) return;
if (rolesAllowed(claims, required)) return;
throw HttpException.forbidden();
}
private void checkScopes(Map<String, Object> claims, String[] required, ScopesAllowed.Match match,
Response res) {
if (required.length == 0) return;
if (scopesAllowed(claims, required, match)) return;
res.header("WWW-Authenticate", insufficientScopeChallenge(required));
throw HttpException.forbidden();
}
static String extractBearerToken(String authorizationHeader) {
if (authorizationHeader == null) return null;
int len = authorizationHeader.length();
int start = 0;
while (start < len && Character.isWhitespace(authorizationHeader.charAt(start))) start++;
int schemeEnd = start + BEARER.length();
if (schemeEnd > len || !authorizationHeader.regionMatches(true, start, BEARER, 0, BEARER.length())) {
return null;
}
if (schemeEnd == len || !Character.isWhitespace(authorizationHeader.charAt(schemeEnd))) {
return null;
}
int tokenStart = schemeEnd;
while (tokenStart < len && Character.isWhitespace(authorizationHeader.charAt(tokenStart))) tokenStart++;
if (tokenStart >= len) return null;
int tokenEnd = len;
while (tokenEnd > tokenStart && Character.isWhitespace(authorizationHeader.charAt(tokenEnd - 1))) tokenEnd--;
return tokenEnd > tokenStart ? authorizationHeader.substring(tokenStart, tokenEnd) : null;
}
String bearerChallenge() {
return bearerChallenge(null, null);
}
private String bearerChallenge(Request req, String resourceMetadataPath) {
String base = BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
if (resourceMetadataPath == null) return base;
return base + ", resource_metadata=\"" + quoted(absoluteSelf(req, resourceMetadataPath)) + "\"";
}
String invalidTokenChallenge() {
return invalidTokenChallenge(null, null);
}
private String invalidTokenChallenge(Request req, String resourceMetadataPath) {
return bearerChallenge(req, resourceMetadataPath) + ", error=\"invalid_token\"";
}
String insufficientScopeChallenge(String[] requiredScopes) {
return bearerChallenge() + ", error=\"insufficient_scope\", scope=\""
+ quoted(spaceDelimited(requiredScopes)) + "\"";
}
private String absoluteSelf(Request req, String path) {
if (!path.startsWith("/")) return path;
return selfOrigin(req, config.selfScheme()) + path;
}
/**
* {@code scheme://host} clients actually reach this app on the basis for every absolute
* URL it publishes about itself (OAuth2 {@code redirect_uri}, the RFC 9728 resource
* identifier and the {@code resource_metadata} challenge). Behind a reverse proxy the
* request's own {@code Host} is the upstream address the proxy dialled, so
* {@code X-Forwarded-Host}/{@code -Proto} win whenever present: without them the app would
* name an address no client can resolve, and OAuth2 discovery fails with no error anyone
* can trace back to here. Trusted unconditionally a caller able to reach this app without
* passing the proxy can do worse than spoof a self URL.
*/
public static String selfOrigin(Request req, String fallbackScheme) {
String forwardedHost = req.header("X-Forwarded-Host");
if (forwardedHost == null) return fallbackScheme + "://" + req.header("Host");
String forwardedProto = req.header("X-Forwarded-Proto");
return (forwardedProto != null ? forwardedProto : fallbackScheme) + "://" + forwardedHost;
}
private static String spaceDelimited(String[] values) {
if (values == null || values.length == 0) return "";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < values.length; i++) {
if (i > 0) sb.append(' ');
sb.append(values[i]);
}
return sb.toString();
}
private static String quoted(String value) {
StringBuilder out = new StringBuilder(value.length() + 8);
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '"' || c == '\\') out.append('\\');
out.append(c);
}
return out.toString();
}
boolean rolesAllowed(Map<String, Object> claims, String[] required) {
Object actual = valueAtPath(claims, roleClaimPathParts);
if (actual == null) return false;
for (String role : required) {
if (containsToken(actual, role)) return true;
}
return false;
}
boolean scopesAllowed(Map<String, Object> claims, String[] required, ScopesAllowed.Match match) {
if (match == ScopesAllowed.Match.ALL) {
for (String scope : required) {
if (!hasScope(claims, scope)) return false;
}
return true;
}
for (String scope : required) {
if (hasScope(claims, scope)) return true;
}
return false;
}
private boolean hasScope(Map<String, Object> claims, String scope) {
for (String[] pathParts : scopeClaimPathParts) {
Object value = valueAtPath(claims, pathParts);
if (value != null && containsToken(value, scope)) return true;
}
return false;
}
private static Object valueAtPath(Map<String, Object> claims, String[] pathParts) {
Object current = claims;
for (String part : pathParts) {
if (!(current instanceof Map<?, ?> map)) return null;
current = map.get(part);
if (current == null) return null;
}
return current;
}
private static boolean containsToken(Object source, String token) {
if (source instanceof String s) return containsDelimitedToken(s, token);
if (source instanceof List<?> list) {
for (Object item : list) {
if (item == null) continue;
if (tokenEquals(item.toString(), token)) return true;
}
return false;
}
if (source instanceof Object[] arr) {
for (Object item : arr) {
if (item == null) continue;
if (tokenEquals(item.toString(), token)) return true;
}
return false;
}
return tokenEquals(source.toString(), token);
}
private static boolean containsDelimitedToken(String value, String token) {
int len = value.length();
int i = 0;
while (i < len) {
while (i < len && isScopeDelimiter(value.charAt(i))) i++;
int start = i;
while (i < len && !isScopeDelimiter(value.charAt(i))) i++;
int end = i;
if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) {
return true;
}
}
return false;
}
private static boolean tokenEquals(String value, String token) {
int start = 0;
int end = value.length();
while (start < end && Character.isWhitespace(value.charAt(start))) start++;
while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--;
return end - start == token.length() && value.regionMatches(start, token, 0, token.length());
}
private static boolean isScopeDelimiter(char c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
}
private static String[] splitClaimPath(String path) {
if (path == null || path.isBlank()) {
throw new IllegalStateException("OIDC claim path cannot be blank");
}
List<String> parts = new ArrayList<>(4);
int start = 0;
int len = path.length();
for (int i = 0; i <= len; i++) {
if (i == len || path.charAt(i) == '.') {
String p = path.substring(start, i).trim();
if (!p.isEmpty()) parts.add(p);
start = i + 1;
}
}
if (parts.isEmpty()) {
throw new IllegalStateException("OIDC claim path cannot be blank");
}
return parts.toArray(String[]::new);
}
private static String[][] splitClaimPaths(String paths) {
String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths;
List<String[]> out = new ArrayList<>(4);
int start = 0;
int len = source.length();
for (int i = 0; i <= len; i++) {
if (i == len || source.charAt(i) == ',') {
String raw = source.substring(start, i).trim();
if (!raw.isEmpty()) out.add(splitClaimPath(raw));
start = i + 1;
}
}
if (out.isEmpty()) {
return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") };
}
return out.toArray(String[][]::new);
}
private static Map<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) {
Map<String, Object> merged = new HashMap<>();
// Fall back to old claims first, then overlay fresh token claims
merged.putAll(old.claims());
if (tokens.accessToken() != null)
merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
if (tokens.idToken() != null)
merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
return Map.copyOf(merged);
}
// -- Shared cookie utility (also used by OidcExtension) -------------------
static String cookieValue(Request req, String name) {
String header = req.header("Cookie");
if (header == null || header.isBlank()) return null;
int len = header.length();
int start = 0;
while (start < len) {
int semi = header.indexOf(';', start);
int end = semi < 0 ? len : semi;
int eq = header.indexOf('=', start);
if (eq > start && eq < end) {
int ns = start, ne = eq;
while (ns < ne && header.charAt(ns) == ' ') ns++;
while (ne > ns && header.charAt(ne-1) == ' ') ne--;
if (ne - ns == name.length() && header.regionMatches(ns, name, 0, name.length()))
return header.substring(eq + 1, end).strip();
}
start = end + 1;
}
return null;
}
}
@@ -1,15 +0,0 @@
package dev.relism.flash.ext.oidc;
/**
* OIDC provider endpoints discovered from {@code {issuer}/.well-known/openid-configuration}.
*
* <p>{@link #endSessionEndpoint()} may be {@code null} not all providers expose it
* (e.g. some Authelia configurations omit it).
*/
public record OidcProviderMetadata(
String authorizationEndpoint,
String tokenEndpoint,
String userinfoEndpoint,
String jwksUri,
String endSessionEndpoint // nullable
) {}
@@ -1,47 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.time.Instant;
import java.util.Map;
/**
* An authenticated user's OIDC session persisted in {@link OidcSessionStore} and
* looked up via the {@code oidc_session} cookie on every request.
*
* <p>Sessions are immutable; a refreshed access token produces a new instance
* that replaces the old one in the store (same {@link #id()}).
*/
public final class OidcSession {
private final String id;
private final String accessToken;
private final String idToken;
private final String refreshToken; // may be null
private final Instant accessTokenExpiresAt;
private final Map<String, Object> claims; // decoded from id_token
public OidcSession(String id, String accessToken, String idToken,
String refreshToken, Instant accessTokenExpiresAt,
Map<String, Object> claims) {
this.id = id;
this.accessToken = accessToken;
this.idToken = idToken;
this.refreshToken = refreshToken;
this.accessTokenExpiresAt = accessTokenExpiresAt;
this.claims = Map.copyOf(claims);
}
/**
* Returns {@code true} if the access token has expired or will expire within
* the next 30 seconds (eager refresh to avoid mid-request expiry).
*/
public boolean isAccessTokenExpired() {
return Instant.now().isAfter(accessTokenExpiresAt.minusSeconds(30));
}
public String id() { return id; }
public String accessToken() { return accessToken; }
public String idToken() { return idToken; }
public String refreshToken() { return refreshToken; }
public Instant accessTokenExpiresAt() { return accessTokenExpiresAt; }
public Map<String, Object> claims() { return claims; }
}
@@ -1,14 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.Optional;
/**
* Backing store for {@link OidcSession} objects. The default implementation is
* {@link InMemoryOidcSessionStore}; supply a custom one via
* {@link OidcConfig.Builder#sessionStore(OidcSessionStore)} for Redis, JDBC, etc.
*/
public interface OidcSessionStore {
void save(OidcSession session);
Optional<OidcSession> find(String sessionId);
void delete(String sessionId);
}
@@ -1,39 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Short-lived store mapping state nonces (original URL, PKCE verifier).
*
* <p>Entries expire after {@value #TTL_SECONDS} seconds. Cleanup runs on every
* access to prevent unbounded growth without needing a background thread.
*/
final class OidcStateStore {
static final int TTL_SECONDS = 600; // 10 minutes
record Entry(String originalUrl, String codeVerifier, String nonce, Instant expiresAt) {}
private final ConcurrentHashMap<String, Entry> store = new ConcurrentHashMap<>();
void put(String state, String originalUrl, String codeVerifier, String nonce) {
cleanup();
store.put(state, new Entry(originalUrl, codeVerifier, nonce,
Instant.now().plusSeconds(TTL_SECONDS)));
}
/** Atomically retrieves and removes the entry; returns empty if absent or expired. */
Optional<Entry> consumeAndRemove(String nonce) {
cleanup();
Entry e = store.remove(nonce);
if (e == null || Instant.now().isAfter(e.expiresAt())) return Optional.empty();
return Optional.of(e);
}
private void cleanup() {
Instant now = Instant.now();
store.entrySet().removeIf(kv -> now.isAfter(kv.getValue().expiresAt()));
}
}

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