Compare commits

4 Commits
Author SHA1 Message Date
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
69 changed files with 2000 additions and 984 deletions
+2 -2
View File
@@ -17,8 +17,8 @@
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-auth-oidc/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-auth-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" />
+5 -3
View File
@@ -11,8 +11,9 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-oidc |
| `flash-extensions/flash-ext-auth-core` | Authentication seam + role/scope authorization |
| `flash-extensions/flash-ext-auth-oidc` | OIDC Authorization Code + PKCE flow |
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-auth-oidc |
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
@@ -146,7 +147,8 @@ FlashApp.create(8080)
See extension-specific READMEs for full details:
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md)
- [`flash-ext-auth-core`](flash-extensions/flash-ext-auth-core/docs/README.md)
- [`flash-ext-auth-oidc`](flash-extensions/flash-ext-auth-oidc/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)
@@ -0,0 +1,105 @@
# flash-ext-auth-core
Authorization, and the plumbing that carries a caller's identity through a request. It does not
know how anyone signed in — that is a `CredentialSource`, and `flash-ext-auth-oidc` ships the
OpenID Connect one.
The split follows the same shape as `flash-ext-cache-core`/`-caffeine` and
`flash-ext-data-core`/`-hibernate`: the abstract half here, the implementations beside it.
## The model
```
request ──► CredentialSource.authenticate(req, res) ──► claims
ClaimsHolder.set (this module only)
AuthMiddleware matches roles / scopes
handler
```
| Type | What it is |
|---|---|
| `CredentialSource` | Turns what a request carries into claims, or rejects it. One per mechanism. |
| `AuthMiddleware` | Publishes the claims, enforces `@RolesAllowed`/`@ScopesAllowed`, clears up. |
| `ClaimsHolder` | The current request's claims. Read from anywhere; written only from here. |
| `Claims` | Typed view over a claims map — `sub()`, `email()`, `roles(path)`, `scopes()`. |
| `AuthPolicy` | What a handler's annotations compiled to, resolved once at boot. |
| `Session`, `SessionStore` | Server-side sessions for sources that keep them. |
Nothing outside this module can write `ClaimsHolder`. A source *returns* claims and the middleware
publishes them, so no code can put claims on a request that did not carry them.
## Using it
You rarely install this module directly — an extension that contributes a source does it for you:
```java
// inside your extension's configure(...)
AuthMiddleware auth = AuthMiddleware.install(ctx, AuthConfig.builder()
.rolesClaimPath("realm_access.roles")
.scopeClaimPaths("scope,scp")
.build(), mySource);
```
`install` publishes the middleware in the context and registers the annotation processor, so every
scanned handler carrying an auth annotation is mounted behind it. See
[`credential-sources.md`](credential-sources.md) to write a source of your own.
On lambda routes, take the middleware out of the context:
```java
AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect());
app.get("/", homeHandler, auth.optional());
app.delete("/admin/users/{id}", deleteHandler, auth.requireRole("admin"));
app.post("/orders", createOrder, auth.requireScopes("orders:write"));
```
## Annotations
On a scanned handler class, and mounted automatically:
| Annotation | Effect |
|---|---|
| `@Authenticated` | Any accepted credential. No role check. |
| `@Authenticated(optional = true)` | Never rejects; publishes claims when there are some. |
| `@RolesAllowed({"a","b"})` | Authenticated **and** holding at least one of the roles. |
| `@ScopesAllowed({"x","y"})` | Authenticated **and** holding all of the scopes. |
| `@ScopesAllowed(value = {...}, match = ANY)` | …at least one of them. |
`@Authenticated(optional = true)` cannot be combined with a role or scope requirement — asking for
a role on a route that admits anonymous callers is a contradiction, and it fails at boot rather
than at 3am.
## Where roles and scopes are read from
`AuthConfig` names the claim paths, because every provider spells them differently:
| | Default | Common alternatives |
|---|---|---|
| `rolesClaimPath` | `roles` | `realm_access.roles` (Keycloak), `groups` (Authelia) |
| `scopeClaimPaths` | `scope,scp` | plus e.g. `permissions.scopes` |
Paths are dot-separated and walk nested maps. Scope paths are a comma-separated list tried in
order, so a token that puts scopes in `scp` and a legacy one that uses `scope` both work.
Matching is deliberate about a distinction that bites otherwise:
- a **string** claim is split on spaces, tabs, newlines and commas — `"openid orders:read"` is two
scopes;
- a **list** claim is compared entry by entry, whole and trimmed — `["a b"]` is one role named
`a b`, not two.
Prefix matches never count: `administrator` does not satisfy `admin`.
## Ordering around authentication
`AuthMiddleware.POLICY` is the boot-time key the annotation-driven node mounts under. An extension
contributing its own middleware can order itself against it:
```java
MiddlewareNode.of(MY_KEY, myMiddleware).afterIfPresent(AuthMiddleware.POLICY);
```
@@ -0,0 +1,95 @@
# Writing a credential source
A `CredentialSource` is the only thing that stands between a request and its claims. Everything
else in this module — annotations, policy, matching, the holder — works the same regardless of
which one is installed.
```java
public interface CredentialSource {
Map<String, Object> authenticate(Request req, Response res);
Map<String, Object> peek(Request req);
default String insufficientScopeChallenge(String[] requiredScopes) { return null; }
}
```
## `authenticate` has three outcomes, and the last two are not the same
| Return | Means | The middleware then |
|---|---|---|
| claims | a valid credential was presented | publishes them and calls the handler |
| `null` | **no** credential, and the source has already answered the request | stops, writes nothing more |
| throws `HttpException` | a credential **was** presented and is invalid | propagates it |
Flattening the last two is the single easiest way to get this wrong. "No session, send the browser
to the sign-in page" and "this token is forged" are different answers, and a caller can tell:
the first is a `302` to a login screen, the second a `401` the client must not retry blindly.
A source that returns `null` owns the response by then — it has redirected, or written a `401` with
its own `WWW-Authenticate` header. A source that throws sets any challenge header it owes *before*
throwing, because the exception unwinds past the middleware.
`peek` is the same resolution with every rejection removed: no throwing, no redirecting, `null`
when there is nothing valid. It backs `@Authenticated(optional = true)`, where an anonymous caller
is a normal outcome. Never make `peek` refresh state that `authenticate` would not have.
## A minimal source
```java
public final class ApiKeySource implements CredentialSource {
private final Map<String, Map<String, Object>> keys; // key -> claims
@Override
public Map<String, Object> authenticate(Request req, Response res) {
String key = req.header("X-Api-Key");
if (key == null) {
res.header("WWW-Authenticate", "ApiKey realm=\"api\"");
throw HttpException.unauthorized();
}
Map<String, Object> claims = keys.get(key);
if (claims == null) throw HttpException.unauthorized(); // presented and wrong
return claims;
}
@Override
public Map<String, Object> peek(Request req) {
String key = req.header("X-Api-Key");
return key != null ? keys.get(key) : null;
}
}
```
This one never returns `null` from `authenticate` — it has no sign-in flow to redirect into, so
"absent" and "invalid" both mean `401`. That is a legitimate shape; the three outcomes are what
the interface *allows*, not a checklist.
## Claims are yours to shape
The claims map is whatever your mechanism produces. `Claims` reads a few conventional keys —
`sub`, `email`, `name`, `preferred_username` — so populating those makes your source work with
code written against any other. Roles and scopes are read from wherever `AuthConfig` points, so
they can live under any key you like as long as the two agree.
## Installing it
```java
public final class ApiKeyExtension implements FlashExtension {
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.provide(ApiKeySource.class, source);
AuthMiddleware.install(ctx, AuthConfig.builder()
.rolesClaimPath("roles")
.build(), source);
}
}
```
`AuthMiddleware.install` also registers the annotation processor, so scanned handlers carrying
`@Authenticated` and friends are mounted behind your source with nothing further to do.
## One source at a time
`AuthMiddleware` is published in the context under its own type, so installing two extensions that
each call `install` leaves the last one winning — quietly. If an app genuinely needs to accept two
kinds of credential, that is one source that tries both, not two sources: the order they are tried
in, and what happens when the first rejects, are decisions that have to live somewhere explicit.
@@ -0,0 +1,49 @@
# Sessions
A `Session` is what a credential source keeps server-side between requests, looked up by a cookie.
Core owns the container; what goes in it is the source's business.
```java
public final class Session {
String id();
Map<String, Object> claims();
Instant expiresAt();
Map<String, Object> attributes();
boolean isExpired();
Object attribute(String key);
String attributeAsString(String key);
}
```
## Why it expires early
`isExpired()` returns true **30 seconds before** `expiresAt`. Without that window a session can
pass the check at the top of a request and be dead by the time the handler uses it — a class of
failure that reproduces once a day and never in a test. Renewal is therefore always slightly
premature, on purpose.
## Attributes
`attributes()` is opaque to this module. `flash-ext-auth-oidc` keeps its access, id and refresh
tokens there under its own keys, which is what lets renewal stay entirely inside that extension
while the session itself carries no OAuth2 vocabulary.
Store what your source needs to renew or revoke, and nothing a handler should be reading — handlers
read `claims()`.
## The store
```java
public interface SessionStore {
void save(Session session);
Optional<Session> find(String sessionId);
void delete(String sessionId);
}
```
`InMemorySessionStore` is the default: a `ConcurrentHashMap`, fine for a single instance, and it
loses every session on restart. Supply your own for Redis or JDBC when sessions have to survive a
deploy or be shared across nodes.
Sessions are immutable. Renewing one builds a new instance with the same `id()` and `save`s it
over the old — there is no mutate-in-place path, so a store can cache or serialise freely.
@@ -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-auth-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,43 @@
package dev.relism.flash.ext.auth;
/**
* Where authorization reads its inputs from. Deliberately small: everything about *obtaining* a
* credential belongs to the {@link CredentialSource} that produced it, and everything about
* *checking* one is right here.
*
* <p>Defaults are the generic spelling, not any one provider's. A source that knows better —
* {@code flash-ext-auth-oidc} defaults roles to Keycloak's {@code realm_access.roles} — builds
* its own {@code AuthConfig} with the paths its provider actually uses.
*/
public final class AuthConfig {
private final String rolesClaimPath;
private final String scopeClaimPaths;
private AuthConfig(Builder b) {
this.rolesClaimPath = b.rolesClaimPath;
this.scopeClaimPaths = b.scopeClaimPaths;
}
/** Dot-separated path to the roles list in the claims (default: {@code roles}). */
public String rolesClaimPath() { return rolesClaimPath; }
/** Comma-separated claim paths scopes are read from, in order (default: {@code scope,scp}). */
public String scopeClaimPaths() { return scopeClaimPaths; }
public static Builder builder() { return new Builder(); }
public static final class Builder {
private String rolesClaimPath = "roles";
private String scopeClaimPaths = "scope,scp";
private Builder() {}
/** Dot-separated path to the roles list — e.g. {@code realm_access.roles}, {@code groups}. */
public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
/** Comma-separated claim paths scopes are read from, tried in order. */
public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; }
public AuthConfig build() { return new AuthConfig(this); }
}
}
@@ -0,0 +1,309 @@
package dev.relism.flash.ext.auth;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Turns claims into a yes or a no. Exposed in the {@link FlashContext} for manual use on lambda
* routes, and injected automatically for handlers annotated with {@link Authenticated},
* {@link RolesAllowed} or {@link ScopesAllowed}.
*
* <p>It knows nothing about how the caller proved who they are — that is the
* {@link CredentialSource} it is built with. What lives here is the half that is the same for
* every mechanism: publish the claims for the request, match roles and scopes against them, clear
* up afterwards.
*
* <pre>{@code
* AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
* app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect());
* app.delete("/admin/users/{id}", handler, auth.requireRole("admin"));
* }</pre>
*/
public class AuthMiddleware {
/**
* Boot-time identity of the node annotation-driven authorization mounts under. Public so an
* extension that contributes its own middleware can order itself around authentication —
* {@code MiddlewareNode.of(...).afterIfPresent(AuthMiddleware.POLICY)}.
*/
public static final MiddlewareKey POLICY = MiddlewareKey.of("flash.auth.policy");
private final AuthConfig config;
private final CredentialSource source;
private final String[] roleClaimPathParts;
private final String[][] scopeClaimPathParts;
public AuthMiddleware(AuthConfig config, CredentialSource source) {
this.config = config;
this.source = source;
this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath());
this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths());
}
/**
* Builds the middleware for {@code source}, publishes it in the context and registers the
* annotation processor that mounts {@link Authenticated}, {@link RolesAllowed} and
* {@link ScopesAllowed} on scanned handlers.
*
* <p>Every extension that contributes a {@link CredentialSource} calls this rather than
* repeating the wiring — the processor and the {@link #POLICY} key belong to one place.
*/
public static AuthMiddleware install(FlashContext ctx, AuthConfig config, CredentialSource source) {
AuthMiddleware middleware = new AuthMiddleware(config, source);
ctx.provide(AuthMiddleware.class, middleware);
ctx.addAnnotationProcessor(handlerClass -> {
AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass);
return policy != null
? List.of(MiddlewareNode.of(POLICY, middleware.authorize(policy)))
: List.of();
});
return middleware;
}
// -- Public API -----------------------------------------------------------
/** The single configured claim path used by every transport for role checks. */
public String rolesClaimPath() { return config.rolesClaimPath(); }
/** The source this middleware authenticates with. */
public CredentialSource source() { return source; }
/**
* The same authorization rules against a different credential source. Used where one route
* needs a variant of an installed source — {@code flash-ext-mcp} protects {@code /mcp} with an
* OIDC source whose challenges carry RFC 9728 resource metadata, while every other route keeps
* the plain one.
*/
public AuthMiddleware withSource(CredentialSource source) {
return new AuthMiddleware(config, source);
}
/**
* Rejects the request unless the caller is authenticated. How it is rejected — a 401 with a
* challenge, a redirect into a sign-in flow — is the source's decision, not this one's.
*/
public Middleware protect() {
return next -> (req, res) -> {
Map<String, Object> claims = source.authenticate(req, res);
if (claims == null) return null; // the source already answered the request
ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/**
* Publishes claims when the caller happens to be authenticated and never rejects anyone. Use
* it on public routes that personalise their response for signed-in callers.
*
* <pre>{@code
* app.get("/", handler, auth.optional());
* // Inside handler: ClaimsHolder.current() is non-null iff the caller is signed in.
* }</pre>
*/
public Middleware optional() {
return next -> (req, res) -> {
Map<String, Object> claims = source.peek(req);
if (claims != null) ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/**
* Applies a policy compiled once at boot from a handler's annotations. This is the path
* annotation-driven mounting takes.
*/
public Middleware authorize(AuthPolicy policy) {
if (policy.optionalAuth()) return optional();
return next -> (req, res) -> {
Map<String, Object> claims = source.authenticate(req, res);
if (claims == null) return null;
enforcePolicy(claims, policy, res);
ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/** {@link #protect()} plus at least one of the given roles (OR semantics). */
public Middleware requireRole(String... roles) {
return authorize(AuthPolicy.rolesAny(roles));
}
/** {@link #protect()} plus every one of the given scopes. */
public Middleware requireScopes(String... scopes) {
return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL));
}
/** {@link #protect()} plus at least one of the given scopes. */
public Middleware requireAnyScope(String... scopes) {
return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY));
}
// -- Policy enforcement ---------------------------------------------------
private void enforcePolicy(Map<String, Object> claims, AuthPolicy 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;
String challenge = source.insufficientScopeChallenge(required);
if (challenge != null) res.header("WWW-Authenticate", challenge);
throw HttpException.forbidden();
}
// -- Claim matching -------------------------------------------------------
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("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("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);
}
}
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.oidc;
package dev.relism.flash.ext.auth;
import java.util.LinkedHashSet;
import java.util.List;
@@ -7,13 +7,13 @@ 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 {
public final class AuthPolicy {
private static final String[] EMPTY = new String[0];
private static final OidcAuthPolicy AUTH_REQUIRED = new OidcAuthPolicy(
private static final AuthPolicy AUTH_REQUIRED = new AuthPolicy(
false, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
private static final OidcAuthPolicy AUTH_OPTIONAL = new OidcAuthPolicy(
private static final AuthPolicy AUTH_OPTIONAL = new AuthPolicy(
true, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
private final boolean optionalAuth;
@@ -21,7 +21,7 @@ final class OidcAuthPolicy {
private final String[] requiredScopes;
private final ScopesAllowed.Match scopeMatch;
private OidcAuthPolicy(boolean optionalAuth,
private AuthPolicy(boolean optionalAuth,
String[] requiredRoles,
String[] requiredScopes,
ScopesAllowed.Match scopeMatch) {
@@ -31,19 +31,19 @@ final class OidcAuthPolicy {
this.scopeMatch = scopeMatch;
}
static OidcAuthPolicy authenticated() { return AUTH_REQUIRED; }
public static AuthPolicy authenticated() { return AUTH_REQUIRED; }
static OidcAuthPolicy optional() { return AUTH_OPTIONAL; }
public static AuthPolicy optional() { return AUTH_OPTIONAL; }
static OidcAuthPolicy rolesAny(String... roles) {
return new OidcAuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL);
public static AuthPolicy rolesAny(String... roles) {
return new AuthPolicy(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);
public static AuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) {
return new AuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match);
}
static OidcAuthPolicy compileFromAnnotations(Class<?> handlerClass) {
public static AuthPolicy compileFromAnnotations(Class<?> handlerClass) {
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
@@ -60,10 +60,10 @@ final class OidcAuthPolicy {
+ handlerClass.getName());
}
return new OidcAuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
return new AuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
}
static List<String> openApiScopesFor(Class<?> handlerClass) {
public static List<String> openApiScopesFor(Class<?> handlerClass) {
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
@@ -72,13 +72,13 @@ final class OidcAuthPolicy {
return List.of(normalizeRequired("ScopesAllowed", scopes.value()));
}
boolean optionalAuth() { return optionalAuth; }
public boolean optionalAuth() { return optionalAuth; }
String[] requiredRoles() { return requiredRoles; }
public String[] requiredRoles() { return requiredRoles; }
String[] requiredScopes() { return requiredScopes; }
public String[] requiredScopes() { return requiredScopes; }
ScopesAllowed.Match scopeMatch() { return scopeMatch; }
public ScopesAllowed.Match scopeMatch() { return scopeMatch; }
private static String[] normalizeRequired(String annotation, String[] values) {
if (values == null || values.length == 0)
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.oidc;
package dev.relism.flash.ext.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
@@ -6,23 +6,23 @@ 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.
* Marks a handler as requiring an authenticated caller. Any credential a registered source
* accepts is enough no role or scope 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.
* <p>Set {@code optional = true} on public routes that personalise their response when the caller
* happens to be signed in but should remain reachable by guests. The middleware populates
* {@link ClaimsHolder} when a credential is present and silently skips it otherwise the request
* is never rejected.
*
* <pre>{@code
* // Hard auth redirects / 401 when unauthenticated:
* // Hard auth 401 or a redirect 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:
* // Soft auth guest-friendly, ClaimsHolder populated only when signed in:
* @Route(method = HttpMethod.GET, path = "/")
* @Authenticated(optional = true)
* public class HomePage extends HtmlHandler { ... }
@@ -1,43 +1,36 @@
package dev.relism.flash.ext.oidc;
package dev.relism.flash.ext.auth;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;
/**
* Type-safe view over the JWT claims stored in {@link ClaimsHolder}.
* A typed view over one request's claims whatever the {@link CredentialSource} that
* authenticated it produced. Obtained from {@link ClaimsHolder#current()}.
*
* <p>Obtainable from any protected context via {@link ClaimsHolder#user()}.
* Class-based handlers that extend the {@code SessionHandler} hierarchy already
* have a provisioned DB user in {@code currentUser}; {@code OidcUser} complements
* that by giving access to the raw OIDC claims when needed, and is the primary
* API for lambda routes.
* <p>The accessors name claim <em>keys</em>, not a protocol: {@code sub} is RFC 7519, and
* {@code email}, {@code name} and {@code preferred_username} are spelled the same way by every
* token issuer worth integrating. A source that uses different keys exposes them through
* {@link #claim(String)} or {@link #roles(String)}.
*
* <pre>{@code
* // Lambda route (OidcMiddleware injected):
* app.get("/api/whoami", (req, res) -> {
* OidcUser u = ClaimsHolder.user();
* return Map.of("sub", u.sub(), "email", u.email(), "roles", u.roles("realm_access.roles"), "scopes", u.scopes());
* }, oidcMw.protect());
*
* // Class-based handler (currentUser is the DB entity; oidcUser() for raw claims):
* protected Object handleAuthenticated(Request req, Response res) throws Exception {
* OidcUser u = oidcUser(); // same as ClaimsHolder.user()
* return json(res, currentUser); // DB entity provisioned from OIDC sub
* }
* Claims c = ClaimsHolder.current();
* return Map.of("sub", c.sub(), "email", c.email(), "roles", c.roles("realm_access.roles"));
* }, auth.protect());
* }</pre>
*/
public final class OidcUser {
public final class Claims {
private final Map<String, Object> claims;
OidcUser(Map<String, Object> claims) {
Claims(Map<String, Object> claims) {
this.claims = claims;
}
// Common OIDC standard claims
// Common claims
/** Subject identifier — unique, stable user ID issued by the provider. */
/** Subject identifier — the stable, unique id of the caller. */
public String sub() { return str("sub"); }
/** User's email address ({@code email} claim). */
@@ -84,7 +77,7 @@ public final class OidcUser {
// -- Scopes ---------------------------------------------------------------
/**
* Resolves OAuth2 scopes from standard OIDC/OAuth claims using fallback order:
* Resolves scopes using the conventional fallback order:
* {@code scope} then {@code scp}. Supports both space-separated string and list forms.
*/
public List<String> scopes() {
@@ -0,0 +1,64 @@
package dev.relism.flash.ext.auth;
import java.util.Map;
/**
* The current request's claims, published by {@link AuthMiddleware} before the handler runs and
* cleared in a {@code finally} afterwards.
*
* <p>Safe with virtual threads: each request gets its own, so a {@link ThreadLocal} is naturally
* isolated per request.
*
* <p>Writing is deliberately not public. A {@link CredentialSource} returns claims and the
* middleware publishes them, so no code outside this module can put claims on a request that did
* not carry them.
*
* <pre>{@code
* // Inside any handler behind @Authenticated or @RolesAllowed:
* Claims caller = ClaimsHolder.current();
* String email = caller.email();
* List<String> roles = caller.roles("realm_access.roles");
*
* // Raw escape hatch:
* Map<String, Object> all = ClaimsHolder.map();
* }</pre>
*/
public final class ClaimsHolder {
private static final ThreadLocal<Map<String, Object>> HOLDER = new ThreadLocal<>();
private ClaimsHolder() {}
/** Called by {@link AuthMiddleware} once a source has authenticated the request. */
static void set(Map<String, Object> claims) {
HOLDER.set(claims);
}
/** Called by {@link AuthMiddleware} in the {@code finally} block. */
static void clear() {
HOLDER.remove();
}
/**
* A typed view of the current request's claims, or {@code null} when the route carries no
* authentication middleware or the caller is anonymous under
* {@link Authenticated}{@code (optional = true)}.
*/
public static Claims current() {
Map<String, Object> claims = HOLDER.get();
return claims != null ? new Claims(claims) : null;
}
/** The raw claims map for the current request, or {@code null}. @see #current() */
public static Map<String, Object> map() {
return HOLDER.get();
}
/** A single claim as a String, or {@code null} when absent or the caller is anonymous. */
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;
}
}
@@ -0,0 +1,48 @@
package dev.relism.flash.ext.auth;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import java.util.Map;
/**
* Turns whatever a request carries — a bearer token, a session cookie, an API key — into the
* claims authorization runs on. One is installed per authentication mechanism;
* {@code flash-ext-auth-oidc} contributes the OpenID Connect one.
*
* <p>Implementations never touch {@link ClaimsHolder}: they produce claims and {@link
* AuthMiddleware} publishes them for the duration of the request. Nothing outside this module can
* inject claims into a request, which is the point.
*/
public interface CredentialSource {
/**
* Resolves the caller's claims, rejecting the request when it cannot.
*
* <p>Three outcomes, and the difference between the last two matters:
* <ul>
* <li>claims — the caller presented a valid credential;</li>
* <li>{@code null} — no credential was presented and this source has already answered the
* request itself (typically a redirect into a sign-in flow). The middleware stops and
* writes nothing more;</li>
* <li>{@link HttpException} — a credential <em>was</em> presented and is invalid. The source
* sets any challenge header it owes the caller before throwing.</li>
* </ul>
*/
Map<String, Object> authenticate(Request req, Response res);
/**
* Resolves claims without ever rejecting: {@code null} when no valid credential is present.
* Backs {@link Authenticated}{@code (optional = true)}, where an anonymous caller is a normal
* outcome rather than a failure.
*/
Map<String, Object> peek(Request req);
/**
* The {@code WWW-Authenticate} value to send with a 403 caused by missing scopes, or
* {@code null} when this source has no such concept. Only consulted after authentication has
* already succeeded.
*/
default String insufficientScopeChallenge(String[] requiredScopes) { return null; }
}
@@ -0,0 +1,20 @@
package dev.relism.flash.ext.auth;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe in-memory {@link SessionStore}.
*
* <p>Sessions are lost on restart and not shared across instances. For
* production deployments with multiple nodes or restart-persistence requirements,
* supply another implementation to whichever {@link CredentialSource} owns the session.
*/
public final class InMemorySessionStore implements SessionStore {
private final ConcurrentHashMap<String, Session> store = new ConcurrentHashMap<>();
@Override public void save(Session s) { store.put(s.id(), s); }
@Override public Optional<Session> find(String id) { return Optional.ofNullable(store.get(id)); }
@Override public void delete(String id) { store.remove(id); }
}
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.oidc;
package dev.relism.flash.ext.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
@@ -6,20 +6,19 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Restricts a handler to callers whose JWT contains at least one of the
* specified roles. Authentication is implicitly required no need to combine
* with {@link Authenticated}.
* Restricts a handler to callers holding at least one of the named roles. Authentication is
* implied there is no need to combine it with {@link Authenticated}.
*
* <p>Roles are read from the claim configured in {@link OidcConfig#rolesClaimPath()}
* (default: {@code "roles"}). Nested paths like {@code "realm_access.roles"} are
* supported with dot notation.
* <p>Roles are read from the claim path the installed credential source is configured with
* (Keycloak's is {@code realm_access.roles}; many providers use a flat {@code roles} or
* {@code groups}). Nested paths use dot notation.
*
* <pre>{@code
* @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
* @RolesAllowed("admin")
* public class DeleteBlog extends JacksonHandler { ... }
*
* // Multiple accepted roles (OR semantics any one role is sufficient):
* // Multiple accepted roles (OR semantics any one is sufficient):
* @RolesAllowed({"admin", "editor"})
* public class UpdateBlog extends JacksonHandler { ... }
* }</pre>
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.oidc;
package dev.relism.flash.ext.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
@@ -6,11 +6,11 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Restricts a handler to callers whose token carries the required OAuth2 scopes.
* Restricts a handler to callers whose credential carries the required scopes.
* Authentication is implicitly required.
*
* <p>Scopes are resolved from the configured claim paths in
* {@link OidcConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support
* {@link AuthConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support
* both standard formats:
* <ul>
* <li>{@code scope}: space-separated string</li>
@@ -0,0 +1,53 @@
package dev.relism.flash.ext.auth;
import java.time.Instant;
import java.util.Map;
/**
* A signed-in caller's server-side session — saved in a {@link SessionStore} and looked up by a
* cookie on every request.
*
* <p>Immutable: renewing one produces a new instance that replaces the old under the same
* {@link #id()}.
*
* <p>{@link #attributes()} is whatever the {@link CredentialSource} needs to keep alongside the
* claims and nothing this module interprets — OpenID Connect stores its access, id and refresh
* tokens there so that renewal is its business rather than core's.
*/
public final class Session {
/** Renew this far before the real expiry, so a session cannot lapse mid-request. */
private static final long EAGER_RENEWAL_SECONDS = 30;
private final String id;
private final Map<String, Object> claims;
private final Instant expiresAt;
private final Map<String, Object> attributes;
public Session(String id, Map<String, Object> claims, Instant expiresAt,
Map<String, Object> attributes) {
this.id = id;
this.claims = Map.copyOf(claims);
this.expiresAt = expiresAt;
this.attributes = attributes == null ? Map.of() : Map.copyOf(attributes);
}
/** True once the session is within {@value #EAGER_RENEWAL_SECONDS} seconds of expiring. */
public boolean isExpired() {
return Instant.now().isAfter(expiresAt.minusSeconds(EAGER_RENEWAL_SECONDS));
}
/** One attribute, or {@code null} when the source never stored it. */
public Object attribute(String key) { return attributes.get(key); }
/** One attribute as a String, or {@code null}. */
public String attributeAsString(String key) {
Object v = attributes.get(key);
return v != null ? v.toString() : null;
}
public String id() { return id; }
public Map<String, Object> claims() { return claims; }
public Instant expiresAt() { return expiresAt; }
public Map<String, Object> attributes() { return attributes; }
}
@@ -0,0 +1,13 @@
package dev.relism.flash.ext.auth;
import java.util.Optional;
/**
* Where {@link Session}s live between requests. {@link InMemorySessionStore} is the default;
* supply another for Redis, JDBC, or anything that survives a restart or spans instances.
*/
public interface SessionStore {
void save(Session session);
Optional<Session> find(String sessionId);
void delete(String sessionId);
}
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.oidc;
package dev.relism.flash.ext.auth;
import org.junit.jupiter.api.Test;
@@ -6,7 +6,7 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class OidcAuthPolicyTest {
class AuthPolicyTest {
static class PlainHandler {}
@@ -33,12 +33,12 @@ class OidcAuthPolicyTest {
@Test
void compileFromAnnotations_noSecurityAnnotations_returnsNull() {
assertNull(OidcAuthPolicy.compileFromAnnotations(PlainHandler.class));
assertNull(AuthPolicy.compileFromAnnotations(PlainHandler.class));
}
@Test
void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(AuthenticatedHandler.class);
AuthPolicy policy = AuthPolicy.compileFromAnnotations(AuthenticatedHandler.class);
assertNotNull(policy);
assertFalse(policy.optionalAuth());
assertEquals(0, policy.requiredRoles().length);
@@ -47,14 +47,14 @@ class OidcAuthPolicyTest {
@Test
void compileFromAnnotations_optionalAuth_createsOptionalPolicy() {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(OptionalHandler.class);
AuthPolicy policy = AuthPolicy.compileFromAnnotations(OptionalHandler.class);
assertNotNull(policy);
assertTrue(policy.optionalAuth());
}
@Test
void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(CombinedHandler.class);
AuthPolicy policy = AuthPolicy.compileFromAnnotations(CombinedHandler.class);
assertNotNull(policy);
assertFalse(policy.optionalAuth());
assertArrayEquals(new String[]{"admin"}, policy.requiredRoles());
@@ -64,7 +64,7 @@ class OidcAuthPolicyTest {
@Test
void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(ScopesHandler.class);
AuthPolicy policy = AuthPolicy.compileFromAnnotations(ScopesHandler.class);
assertNotNull(policy);
assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes());
assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch());
@@ -73,22 +73,22 @@ class OidcAuthPolicyTest {
@Test
void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() {
assertThrows(IllegalStateException.class,
() -> OidcAuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class));
() -> AuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class));
}
@Test
void openApiScopesFor_returnsScopesWhenPresent() {
assertEquals(List.of("orders:write", "payments:write"),
OidcAuthPolicy.openApiScopesFor(ScopesHandler.class));
AuthPolicy.openApiScopesFor(ScopesHandler.class));
}
@Test
void openApiScopesFor_rolesOnly_returnsEmptyList() {
assertEquals(List.of(), OidcAuthPolicy.openApiScopesFor(RolesHandler.class));
assertEquals(List.of(), AuthPolicy.openApiScopesFor(RolesHandler.class));
}
@Test
void openApiScopesFor_noSecurity_returnsNull() {
assertNull(OidcAuthPolicy.openApiScopesFor(PlainHandler.class));
assertNull(AuthPolicy.openApiScopesFor(PlainHandler.class));
}
}
@@ -0,0 +1,209 @@
package dev.relism.flash.ext.auth;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Characterisation tests for claim matching — the part of authorization that has nothing to do
* with OIDC: given a claims map, does the caller hold a role or a scope.
*
* <p>Written to pin the <em>current</em> behaviour, including the edges that are easy to change by
* accident: which characters separate scopes in a string claim, whether a list entry is trimmed
* before comparison, what an empty requirement means under each match mode, and how a claim path
* that walks into a non-map resolves. Every assertion here reflects what the code does today, not
* what it arguably should do.
*/
class ClaimMatchingTest {
/** No credential source: every assertion here is about claims that are already resolved. */
private static AuthMiddleware middleware(String rolesPath, String scopePaths) {
return new AuthMiddleware(AuthConfig.builder()
.rolesClaimPath(rolesPath)
.scopeClaimPaths(scopePaths)
.build(), null);
}
private static AuthMiddleware middleware() {
return middleware("realm_access.roles", "scope,scp");
}
// ── Claim path traversal ─────────────────────────────────────────────────
@Test
void aPathWalksNestedMaps() {
Map<String, Object> claims = Map.of("a", Map.of("b", Map.of("c", List.of("x"))));
assertTrue(middleware("a.b.c", "scope").rolesAllowed(claims, new String[]{"x"}));
}
@Test
void aPathThatWalksIntoANonMapResolvesToNothing() {
// "a" is a string, so "a.b" has nowhere to go — not an error, just no match.
Map<String, Object> claims = Map.of("a", "not-a-map");
assertFalse(middleware("a.b", "scope").rolesAllowed(claims, new String[]{"anything"}));
}
@Test
void aMissingPathResolvesToNothing() {
assertFalse(middleware().rolesAllowed(Map.of("other", "value"), new String[]{"admin"}));
}
@Test
void emptySegmentsInAPathAreSkipped() {
// "realm_access..roles" collapses to the same two segments.
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
assertTrue(middleware("realm_access..roles", "scope").rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void segmentsAreTrimmed() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
assertTrue(middleware(" realm_access . roles ", "scope").rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void aBlankRolesPathIsRejectedAtConstruction() {
assertThrows(IllegalStateException.class, () -> middleware(" ", "scope"));
}
@Test
void aNullClaimValueResolvesToNothing() {
Map<String, Object> nested = new HashMap<>();
nested.put("roles", null);
Map<String, Object> claims = Map.of("realm_access", nested);
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
// ── Roles: ANY semantics ─────────────────────────────────────────────────
@Test
void anyOneOfTheRequiredRolesIsEnough() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("user")));
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin", "user"}));
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin", "ops"}));
}
@Test
void requiringNoRoleAtAllMatchesNothing() {
// The loop never runs, so the answer is false even when the claim is present.
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
assertFalse(middleware().rolesAllowed(claims, new String[0]));
}
// ── What counts as "contains" ────────────────────────────────────────────
@Test
void aListClaimMatchesEntrywiseAndTrimsEachEntry() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of(" admin ", "user")));
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void aListEntryIsNeverSplitOnDelimiters() {
// Unlike a string claim, a list entry is compared whole: "a b" is one role named "a b".
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("a b")));
assertFalse(middleware().rolesAllowed(claims, new String[]{"a"}));
assertTrue(middleware().rolesAllowed(claims, new String[]{"a b"}));
}
@Test
void nullEntriesInAListAreSkipped() {
Map<String, Object> claims = Map.of("realm_access",
Map.of("roles", Arrays.asList(null, "admin")));
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void anArrayClaimBehavesLikeAList() {
Map<String, Object> claims = Map.of("realm_access",
Map.of("roles", (Object) new String[]{"admin", "user"}));
assertTrue(middleware().rolesAllowed(claims, new String[]{"user"}));
}
@Test
void aScalarClaimIsComparedWhole() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", 42));
assertTrue(middleware().rolesAllowed(claims, new String[]{"42"}));
}
@Test
void aStringClaimIsSplitOnSpacesTabsNewlinesAndCommas() {
for (String separator : List.of(" ", "\t", "\n", "\r", ",")) {
Map<String, Object> claims = Map.of("realm_access",
Map.of("roles", "admin" + separator + "user"));
assertTrue(middleware().rolesAllowed(claims, new String[]{"user"}),
"separator " + separator.strip().isEmpty() + " should split the claim");
}
}
@Test
void aStringClaimDoesNotMatchAPrefixOrASubstring() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", "administrator"));
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void repeatedDelimitersProduceNoEmptyTokens() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", " ,, admin ,, "));
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
// ── Scopes: ALL vs ANY, across several claim paths ───────────────────────
@Test
void allRequiresEveryScope() {
Map<String, Object> claims = Map.of("scope", "openid orders:read");
assertTrue(middleware().scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL));
assertFalse(middleware().scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL));
}
@Test
void anyRequiresOne() {
Map<String, Object> claims = Map.of("scope", "openid");
assertTrue(middleware().scopesAllowed(claims, new String[]{"nope", "openid"}, ScopesAllowed.Match.ANY));
assertFalse(middleware().scopesAllowed(claims, new String[]{"nope", "neither"}, ScopesAllowed.Match.ANY));
}
@Test
void requiringNoScopeIsVacuouslyTrueUnderAllAndFalseUnderAny() {
// The asymmetry falls out of the loops and is load-bearing for @ScopesAllowed's validation,
// which rejects an empty value list before it can ever reach here.
Map<String, Object> claims = Map.of("scope", "openid");
assertTrue(middleware().scopesAllowed(claims, new String[0], ScopesAllowed.Match.ALL));
assertFalse(middleware().scopesAllowed(claims, new String[0], ScopesAllowed.Match.ANY));
}
@Test
void scopesAreLookedForInEveryConfiguredPathUntilOneMatches() {
AuthMiddleware mw = middleware("roles", "scope, scp , permissions.scopes");
Map<String, Object> claims = Map.of(
"scp", List.of("payments:write"),
"permissions", Map.of("scopes", "orders:approve"));
assertTrue(mw.scopesAllowed(claims, new String[]{"payments:write"}, ScopesAllowed.Match.ALL));
assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve"}, ScopesAllowed.Match.ALL));
// ALL is satisfied even when the two scopes come from different claims.
assertTrue(mw.scopesAllowed(claims,
new String[]{"payments:write", "orders:approve"}, ScopesAllowed.Match.ALL));
}
@Test
void blankScopePathsFallBackToScopeAndScp() {
AuthMiddleware mw = middleware("roles", " ");
assertTrue(mw.scopesAllowed(Map.of("scope", "a"), new String[]{"a"}, ScopesAllowed.Match.ALL));
assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL));
}
@Test
void aScopePathListOfOnlySeparatorsFallsBackToScopeAndScp() {
AuthMiddleware mw = middleware("roles", " , , ");
assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL));
}
}
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.oidc;
package dev.relism.flash.ext.auth;
import org.junit.jupiter.api.Test;
@@ -7,11 +7,11 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class OidcUserScopesTest {
class ClaimsScopesTest {
@Test
void scopes_readsStandardScopeString() {
OidcUser user = new OidcUser(Map.of("scope", "openid profile orders:read"));
Claims user = new Claims(Map.of("scope", "openid profile orders:read"));
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes());
assertTrue(user.hasScope("orders:read"));
@@ -20,7 +20,7 @@ class OidcUserScopesTest {
@Test
void scopes_fallsBackToScpArray() {
OidcUser user = new OidcUser(Map.of("scp", List.of("orders:write", "payments:write")));
Claims user = new Claims(Map.of("scp", List.of("orders:write", "payments:write")));
assertEquals(List.of("orders:write", "payments:write"), user.scopes());
assertTrue(user.hasScope("payments:write"));
@@ -28,7 +28,7 @@ class OidcUserScopesTest {
@Test
void scopes_supportsCustomClaimPaths() {
OidcUser user = new OidcUser(Map.of("permissions", Map.of("scopes", List.of("a", "b"))));
Claims user = new Claims(Map.of("permissions", Map.of("scopes", List.of("a", "b"))));
assertEquals(List.of("a", "b"), user.scopes("permissions.scopes"));
assertTrue(user.hasScope("permissions.scopes", "a"));
@@ -37,7 +37,7 @@ class OidcUserScopesTest {
@Test
void scopes_combinesMultipleClaimPathsInOrder() {
OidcUser user = new OidcUser(Map.of(
Claims user = new Claims(Map.of(
"scope", "openid",
"scp", List.of("profile", "orders:read")
));
@@ -0,0 +1,58 @@
package dev.relism.flash.ext.auth;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The two things a session has to get right: it reports itself expired early enough that it
* cannot lapse midway through a request, and it hands back what a credential source stored on it
* without interpreting any of it.
*/
class SessionTest {
private static Session at(Instant expiry, Map<String, Object> attributes) {
return new Session("s1", Map.of("sub", "u1"), expiry, attributes);
}
@Test
void aSessionIsExpiredWellBeforeItsDeadline() {
// The eager window is what stops a session from lapsing between the check and the handler.
assertFalse(at(Instant.now().plusSeconds(120), Map.of()).isExpired());
assertTrue(at(Instant.now().plusSeconds(10), Map.of()).isExpired());
assertTrue(at(Instant.now().minusSeconds(1), Map.of()).isExpired());
}
@Test
void attributesAreReturnedUninterpreted() {
Session session = at(Instant.now().plusSeconds(60), Map.of("oidc.id_token", "abc", "n", 7));
assertEquals("abc", session.attributeAsString("oidc.id_token"));
assertEquals("7", session.attributeAsString("n"));
assertEquals(7, session.attribute("n"));
assertNull(session.attributeAsString("absent"));
}
@Test
void aSessionWithoutAttributesIsUsableRatherThanNull() {
assertNull(at(Instant.now().plusSeconds(60), null).attributeAsString("anything"));
}
@Test
void claimsAndAttributesAreCopiedAndImmutable() {
Map<String, Object> mutable = new HashMap<>(Map.of("k", "v"));
Session session = at(Instant.now().plusSeconds(60), mutable);
mutable.put("k", "changed");
assertEquals("v", session.attributeAsString("k"));
assertThrows(UnsupportedOperationException.class, () -> session.attributes().put("x", "y"));
assertThrows(UnsupportedOperationException.class, () -> session.claims().put("x", "y"));
}
}
@@ -1,4 +1,4 @@
# flash-ext-oidc
# flash-ext-auth-oidc
Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
@@ -6,6 +6,11 @@ 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).
This extension is the OpenID Connect **credential source** for
[`flash-ext-auth-core`](../flash-ext-auth-core/docs/README.md), which owns everything downstream of
identifying the caller. Shorter guides live in [`docs/`](docs/README.md), including
[migration notes](docs/interop.md#migrating-from-flash-ext-oidc) from `flash-ext-oidc`.
## What it provides
| Component | Description |
@@ -13,24 +18,25 @@ hot-path model (middleware compiled at mount time, no heavy runtime work).
| `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 |
| `OidcCredentialSource` | The `CredentialSource` this extension contributes to `flash-ext-auth-core` |
| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
`@Authenticated`, `@RolesAllowed`, `@ScopesAllowed`, `AuthMiddleware`, `ClaimsHolder` and `Claims`
belong to [`flash-ext-auth-core`](../flash-ext-auth-core/docs/README.md) and work the same behind
any credential source. Installing this extension brings them in and wires them up — you do not
install auth-core yourself.
## Dependencies
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId>
<version>1.0-SNAPSHOT</version>
<artifactId>flash-ext-auth-oidc</artifactId>
<version>2.1.0-SNAPSHOT</version>
</dependency>
```
Transitive: `nimbus-jose-jwt`, `json-smart`.
Transitive: `flash-ext-auth-core`, `nimbus-jose-jwt`, `json-smart`.
Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically.
## Installation
@@ -98,7 +104,7 @@ OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/cal
| `.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) |
| `.sessionStore(store)` | `InMemorySessionStore` | 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 |
@@ -130,7 +136,7 @@ OIDC_CLIENT_AUTH_METHOD default: POST
public class MePage extends JacksonHandler {
@Override
public Object handle(Request req, Response res) {
OidcUser u = ClaimsHolder.user();
Claims u = ClaimsHolder.current();
return json(res, Map.of("sub", u.sub(), "email", u.email()));
}
}
@@ -166,49 +172,50 @@ Annotation composition rules:
### Lambda routes (manual middleware)
For lambda routes, pass the middleware as a varargs argument. Retrieve `OidcMiddleware`
For lambda routes, pass the middleware as a varargs argument. Retrieve `AuthMiddleware`
from the context inside another extension's `routes()` phase, or after `start()`:
```java
OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
// Authentication only
app.get("/api/me", (req, res) -> {
OidcUser u = ClaimsHolder.user(); // never null here
Claims u = ClaimsHolder.current(); // never null here
return Map.of("sub", u.sub(), "email", u.email());
}, oidc.protect());
}, auth.protect());
// Authentication + role check
app.delete("/api/admin/users/{id}", (req, res) -> {
OidcUser u = ClaimsHolder.user();
Claims u = ClaimsHolder.current();
// ...
}, oidc.requireRole("admin"));
}, auth.requireRole("admin"));
// Multiple roles (OR): passes if user holds any one of them
app.get("/api/reports", (req, res) -> { ... }, oidc.requireRole("admin", "reports-viewer"));
app.get("/api/reports", (req, res) -> { ... }, auth.requireRole("admin", "reports-viewer"));
// Require all listed scopes
app.post("/api/orders", (req, res) -> { ... }, oidc.requireScopes("orders:write", "payments:write"));
app.post("/api/orders", (req, res) -> { ... }, auth.requireScopes("orders:write", "payments:write"));
// Require at least one listed scope
app.post("/api/payments", (req, res) -> { ... }, oidc.requireAnyScope("payments:write", "payments:admin"));
app.post("/api/payments", (req, res) -> { ... }, auth.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
`auth.protect()` / `auth.requireRole(...)` / `auth.requireScopes(...)` return a `Middleware` — a composable
`Handler → Handler` wrapper. Flash applies middleware right-to-left so the authentication 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
It is populated by `AuthMiddleware` — from `flash-ext-auth-core` — once this source has
authenticated the request, and cleared in the `finally` block afterward. Nothing outside that
module can write to it. It is safe with virtual threads (each request gets its
own virtual thread, so `ThreadLocal` values are naturally isolated).
### OidcUser (preferred)
### Claims (preferred)
```java
OidcUser u = ClaimsHolder.user(); // never null inside a protected handler
Claims u = ClaimsHolder.current(); // never null inside a protected handler
String sub = u.sub(); // unique user ID
String email = u.email();
@@ -241,7 +248,7 @@ Map<String, Object> all = u.claims();
### Raw access (escape hatch)
```java
Map<String, Object> claims = ClaimsHolder.get();
Map<String, Object> claims = ClaimsHolder.map();
String email = ClaimsHolder.claim("email");
```
@@ -340,7 +347,7 @@ Quick path to test `@ScopesAllowed` end-to-end:
- 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
- or `auth.requireScopes("orders:write")` for lambda routes
6. **Verify behavior**
- token with scope -> 200
- token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope`
@@ -354,24 +361,29 @@ Useful token inspection flow while testing:
## Session store
The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments.
For clustered deployments, implement `OidcSessionStore`:
Sessions live in `flash-ext-auth-core`'s `Session`/`SessionStore`; this extension keeps its
access, id and refresh tokens in `Session.attributes()` under its own keys, so renewal stays here
and core carries no OAuth2 vocabulary. See
[`../flash-ext-auth-core/docs/sessions.md`](../flash-ext-auth-core/docs/sessions.md).
The default `InMemorySessionStore` is sufficient for single-instance deployments.
For clustered deployments, implement `SessionStore`:
```java
public interface OidcSessionStore {
void save(OidcSession session);
Optional<OidcSession> find(String sessionId);
public interface SessionStore {
void save(Session session);
Optional<Session> find(String sessionId);
void delete(String sessionId);
}
```
```java
OidcConfig.builder(...)
.sessionStore(new RedisOidcSessionStore(redisClient))
.sessionStore(new RedisSessionStore(redisClient))
.build()
```
`OidcSession` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map).
`Session` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map).
## Logout
@@ -401,7 +413,7 @@ Authorization: Bearer <access_token>
```
The token must be a JWT (opaque tokens are not supported). Claims are available via
`ClaimsHolder.user()` as usual.
`ClaimsHolder.current()` as usual.
## Multi-tenant
@@ -420,7 +432,7 @@ app.install(new OidcExtension(tenantA))
```
To reference a specific tenant's middleware on lambda routes, keep the extension instances
and retrieve `OidcMiddleware` from context after `start()`:
and retrieve `AuthMiddleware` from context after `start()`:
```java
OidcExtension extA = new OidcExtension(tenantA);
@@ -432,10 +444,10 @@ FlashApp app = FlashApp.create(8080)
.start()
.join(); // wait for bind
OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // last registered = tenantB
AuthMiddleware mwA = app.ctx().require(AuthMiddleware.class); // last registered = tenantB
```
> **Note:** because both extensions register `OidcMiddleware.class` in the same context,
> **Note:** because both extensions register `AuthMiddleware.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()`.
@@ -0,0 +1,98 @@
# flash-ext-auth-oidc
OpenID Connect for Flash: the authorization-code flow with PKCE, JWKS-validated bearer tokens,
server-side sessions with silent refresh, and single logout.
It is a **credential source** for [`flash-ext-auth-core`](../../flash-ext-auth-core/docs/README.md),
which owns everything downstream of "who is this caller" — `@Authenticated`, `@RolesAllowed`,
`@ScopesAllowed`, `ClaimsHolder`. Installing this extension installs that machinery too; you do not
install `flash-ext-auth-core` yourself.
## Quick start
```java
app.install(new OidcExtension(
OidcConfig.builder(
"https://keycloak.example.com/realms/myrealm",
"my-app", "secret", "/auth/callback")
.rolesClaimPath("realm_access.roles")
.https()
.build()));
```
That is the whole integration. Discovery runs at boot and fails fast if the issuer is unreachable,
so a misconfigured provider is a startup crash rather than a 500 on the first login.
`OidcConfig.fromEnv()` reads the same settings from `OIDC_*` environment variables, and
`OidcConfig.keycloak(serverUrl, realm, ...)` builds the issuer URL for you.
## What it registers
| | |
|---|---|
| `GET {prefix}/login` | Builds the authorization URL with PKCE + state and redirects |
| `GET {prefix}/callback` | Validates state and nonce, exchanges the code, creates the session |
| `POST {prefix}/logout` | Ends the session and redirects to the provider's end-session endpoint |
`{prefix}` is `routePrefix` (default `/auth`). Logout is a `POST` on purpose — a `GET` logout is
one `<img>` tag away from being triggered by any page the user visits.
In the context it provides `AuthMiddleware` (from auth-core), `OidcCredentialSource` and
`JwtValidator`.
## How a request is resolved
1. `Authorization: Bearer …` — validated against the issuer's JWKS.
2. `oidc_session` cookie — looked up in the `SessionStore`; if the access token has expired and a
refresh token is present, refreshed transparently and the session replaced.
3. Neither, and the client sent `Accept: application/json``401` with a
`WWW-Authenticate: Bearer` challenge.
4. Neither, and it looks like a browser → redirect to `{prefix}/login?redirect={path}`.
Points 3 and 4 are why the source distinguishes "no credential" from "bad credential": an API
client must not be redirected into an HTML sign-in page, and a browser must not be left staring at
a bare 401.
## Configuration
| Setting | Default | Notes |
|---|---|---|
| `issuer`, `clientId`, `clientSecret`, `redirectUri` | — | required |
| `scopes` | `openid profile email` | |
| `routePrefix` | `/auth` | |
| `selfScheme` | `http` | `https()` behind TLS; only used when no `X-Forwarded-Proto` |
| `rolesClaimPath` | `realm_access.roles` | Keycloak's spelling; `groups` for Authelia |
| `scopeClaimPaths` | `scope,scp` | comma-separated, tried in order |
| `algorithm` | `RS256` | |
| `postLogoutRedirectUri` | `/` | |
| `sessionStore` | `InMemorySessionStore` | swap for Redis/JDBC across instances |
| `clientAuthMethod` | `POST` | token endpoint client authentication |
| `insecureTls()` | off | dev only, skips certificate validation |
| `schemeName` | derived from the issuer | OpenAPI security scheme name |
A relative `redirectUri` (starting with `/`) is resolved per request against the incoming `Host`,
or `X-Forwarded-Host`/`-Proto` when behind a proxy — so one build works in dev and behind TLS
without a second configuration.
## Sessions
A session holds the claims plus the access, id and refresh tokens, the last three in
`Session.attributes()` under this extension's own keys. Core never reads them; renewal happens
here. See [`../../flash-ext-auth-core/docs/sessions.md`](../../flash-ext-auth-core/docs/sessions.md).
## Multiple providers
Two issuers on one server, each with its own route prefix:
```java
app.install(new OidcExtension(tenantAConfig)) // routePrefix("/tenantA/auth")
.install(new OidcExtension(tenantBConfig)); // routePrefix("/tenantB/auth")
```
Both are known at boot. Registering an issuer at runtime — a customer connecting their own IdP from
a settings page — is not supported.
## Interop
See [`interop.md`](interop.md) for how this extension fits with `flash-ext-auth-core`,
`flash-ext-openapi` and `flash-ext-mcp`.
@@ -0,0 +1,88 @@
# Interop
## flash-ext-auth-core
A hard dependency, and the reason this extension is as small as it is. The division:
| Here | `flash-ext-auth-core` |
|---|---|
| Discovery, JWKS, PKCE, token endpoint | `@Authenticated`, `@RolesAllowed`, `@ScopesAllowed` |
| `/login`, `/callback`, `/logout` | `ClaimsHolder`, `Claims` |
| Bearer and cookie resolution, refresh | Role and scope matching |
| RFC 6750 `WWW-Authenticate` challenges | `Session`, `SessionStore` |
`OidcExtension` builds an `OidcCredentialSource`, hands it to `AuthMiddleware.install(...)`, and
that publishes the middleware and registers the annotation processor. Everything a handler
annotation does is core's code running against claims this extension produced.
Consequence worth knowing: `@RolesAllowed` is not OIDC-specific and never was. An app that swaps
this extension for another credential source keeps every annotation it had.
## flash-ext-openapi
Optional, and resolved lazily so this extension runs standalone when openapi is not on the
classpath. When it is, an `OpenApiContributor` is registered that emits an `oauth2` security scheme
with the `authorizationCode` flow, filled in from the discovery document:
```json
"securitySchemes": {
"myrealm": {
"type": "oauth2",
"flows": { "authorizationCode": { "authorizationUrl": "…", "tokenUrl": "…", "scopes": {} } }
}
}
```
Per-operation security comes from the same annotations the middleware reads, so the spec and the
enforcement cannot drift: both call `AuthPolicy.compileFromAnnotations`.
The scheme name is `schemeName`, derived from the last path segment of the issuer (a Keycloak realm
name, usually) unless set explicitly.
## flash-ext-mcp
`McpSecurity` asks whether **this** extension is installed — `ctx.find(OidcCredentialSource.class)`
— and not merely whether something authenticates:
| Policy | this extension installed | absent |
|---|---|---|
| `REQUIRED` | protected | **boot fails** |
| `AUTO` | protected | unprotected, warning logged |
| `NONE` | never protected | unprotected |
That distinction is deliberate. `REQUIRED` means "a real OAuth2 authorization server is protecting
this endpoint", because everything it turns on — RFC 9728 Protected Resource Metadata, RFC 8707
audience binding, `WWW-Authenticate` challenges carrying `resource_metadata` — is meaningless
without an issuer. An app that authenticates some other way must not satisfy it by accident.
When it is installed, `McpOidcIntegration` derives the whole resource-server configuration from the
source with no extra `McpConfig` calls:
- the MCP route is wrapped with `authMw.withSource(source.withResourceMetadata(path)).protect()`
the same validation every other route uses, plus the `resource_metadata` challenge parameter;
- an audience guard runs after it and rejects any token whose `aud` does not include this
endpoint's resource identifier;
- the resource identifier is resolved per request from `X-Forwarded-Host`/`-Proto`, or the `Host`
header and `selfScheme`.
An app that does **not** use OAuth2 can still guard `/mcp`: set `McpSecurity.NONE` and pass its own
guard to `McpConfig.middleware(...)`.
## Migrating from flash-ext-oidc
The module was renamed and its generic half moved. Mechanically:
| Was | Now |
|---|---|
| `flash-ext-oidc` (artifact) | `flash-ext-auth-oidc` |
| `dev.relism.flash.ext.oidc.Authenticated` (and `RolesAllowed`, `ScopesAllowed`) | `dev.relism.flash.ext.auth.…` |
| `OidcMiddleware` | `AuthMiddleware` (`dev.relism.flash.ext.auth`) |
| `ctx.find(OidcMiddleware.class)` | `ctx.find(AuthMiddleware.class)` |
| `OidcUser` | `Claims` |
| `ClaimsHolder.user()` | `ClaimsHolder.current()` |
| `ClaimsHolder.get()` | `ClaimsHolder.map()` |
| `OidcSession`, `OidcSessionStore`, `InMemoryOidcSessionStore` | `Session`, `SessionStore`, `InMemorySessionStore` |
| `session.isAccessTokenExpired()` | `session.isExpired()` |
| `session.idToken()` | `session.attributeAsString(OidcCredentialSource.ID_TOKEN)` |
`OidcConfig`, `OidcExtension` and every setting on them are unchanged.
@@ -10,9 +10,13 @@
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-oidc</artifactId>
<artifactId>flash-ext-auth-oidc</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-auth-core</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
@@ -1,5 +1,8 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.ext.auth.InMemorySessionStore;
import dev.relism.flash.ext.auth.SessionStore;
/**
* Full OIDC client configuration. Build via
* {@link #builder(String, String, String, String)} or {@link #fromEnv()}.
@@ -50,7 +53,7 @@ public final class OidcConfig {
private final String scopeClaimPaths;
private final String algorithm;
private final String postLogoutRedirectUri;
private final OidcSessionStore sessionStore;
private final SessionStore sessionStore;
private final boolean insecureTls;
private final ClientAuthMethod clientAuthMethod;
private final String schemeName;
@@ -68,7 +71,7 @@ public final class OidcConfig {
this.algorithm = b.algorithm;
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
this.sessionStore = b.sessionStore != null ? b.sessionStore
: new InMemoryOidcSessionStore();
: new InMemorySessionStore();
this.insecureTls = b.insecureTls;
this.clientAuthMethod = b.clientAuthMethod;
this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer);
@@ -88,7 +91,7 @@ public final class OidcConfig {
public String scopeClaimPaths() { return scopeClaimPaths; }
public String algorithm() { return algorithm; }
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
public OidcSessionStore sessionStore() { return sessionStore; }
public SessionStore 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; }
@@ -190,7 +193,7 @@ public final class OidcConfig {
private String scopeClaimPaths = "scope,scp";
private String algorithm = "RS256";
private String postLogoutRedirectUri = "/";
private OidcSessionStore sessionStore;
private SessionStore sessionStore;
private boolean insecureTls = false;
private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
private String schemeName = null;
@@ -218,8 +221,8 @@ public final class OidcConfig {
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; }
/** Custom session store (default: {@link InMemorySessionStore}). */
public Builder sessionStore(SessionStore 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>
@@ -0,0 +1,333 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.auth.CredentialSource;
import dev.relism.flash.ext.auth.Session;
import dev.relism.flash.models.Response;
import dev.relism.flash.models.Request;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* The OpenID Connect {@link CredentialSource}: it turns what a request carries into claims, and
* rejects it the way OAuth2 says to when it cannot. Authorization on those claims is
* {@code flash-ext-auth-core}'s job, not this class's.
*
* <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 dev.relism.flash.ext.auth.SessionStore}; 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 with a {@code WWW-Authenticate: Bearer} challenge.</li>
* </ol>
*/
public final class OidcCredentialSource implements CredentialSource {
private static final String BEARER = "Bearer";
/**
* Keys this source stores its OAuth2 tokens under in {@link Session#attributes()}. Core keeps
* the session; the tokens inside it are nobody else's business.
*/
static final String ACCESS_TOKEN = "oidc.access_token";
static final String ID_TOKEN = "oidc.id_token";
static final String REFRESH_TOKEN = "oidc.refresh_token";
/** The one place an OIDC session is built, so its attribute keys stay in one place too. */
static Session newSession(String id, String accessToken, String idToken, String refreshToken,
Instant expiresAt, Map<String, Object> claims) {
Map<String, Object> attributes = new HashMap<>(3);
if (accessToken != null) attributes.put(ACCESS_TOKEN, accessToken);
if (idToken != null) attributes.put(ID_TOKEN, idToken);
if (refreshToken != null) attributes.put(REFRESH_TOKEN, refreshToken);
return new Session(id, claims, expiresAt, attributes);
}
private final JwtValidator validator;
private final OidcConfig config;
private final OidcProviderMetadata meta;
private final TokenClient tokenClient;
private final String resourceMetadataPath;
OidcCredentialSource(JwtValidator validator, OidcConfig config,
OidcProviderMetadata meta, TokenClient tokenClient) {
this(validator, config, meta, tokenClient, null);
}
private OidcCredentialSource(JwtValidator validator, OidcConfig config,
OidcProviderMetadata meta, TokenClient tokenClient,
String resourceMetadataPath) {
this.validator = validator;
this.config = config;
this.meta = meta;
this.tokenClient = tokenClient;
this.resourceMetadataPath = resourceMetadataPath;
}
// -- CredentialSource -----------------------------------------------------
/** OIDC issuer this source 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(); }
/**
* A copy of this source whose 401 challenges also carry {@code resource_metadata}
* (RFC 9728 §5.1), resolved against the request's own scheme and host exactly like
* {@link OidcExtension}'s redirect URIs. {@code path} is absolute, e.g.
* {@code "/.well-known/oauth-protected-resource/mcp"}.
*
* <p>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 OidcCredentialSource withResourceMetadata(String path) {
return new OidcCredentialSource(validator, config, meta, tokenClient, path);
}
@Override
public Map<String, Object> authenticate(Request req, Response res) {
return resolve(req, res, resourceMetadataPath);
}
@Override
public Map<String, Object> peek(Request req) {
return resolveQuiet(req);
}
@Override
public String insufficientScopeChallenge(String[] requiredScopes) {
return bearerChallenge() + ", error=\"insufficient_scope\", scope=\""
+ quoted(spaceDelimited(requiredScopes)) + "\"";
}
// -- 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<Session> found = config.sessionStore().find(sessionId);
if (found.isPresent()) {
Session session = found.get();
if (!session.isExpired())
return session.claims();
if (session.attributeAsString(REFRESH_TOKEN) != null) {
try {
Session 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<Session> found = config.sessionStore().find(sessionId);
if (found.isPresent()) {
Session session = found.get();
if (!session.isExpired())
return session.claims();
// Access token expired — try silent refresh
if (session.attributeAsString(REFRESH_TOKEN) != null) {
try {
Session 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 Session doRefresh(Session old) throws Exception {
OidcTokenResponse tokens = tokenClient.refresh(
meta.tokenEndpoint(), old.attributeAsString(REFRESH_TOKEN));
return newSession(
old.id(),
tokens.accessToken(),
tokens.idToken() != null ? tokens.idToken() : old.attributeAsString(ID_TOKEN),
tokens.refreshToken() != null ? tokens.refreshToken() : old.attributeAsString(REFRESH_TOKEN),
Instant.now().plusSeconds(tokens.expiresIn()),
mergeRefreshedClaims(tokens, old)
);
}
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\"";
}
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();
}
private static Map<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, Session 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;
}
}
@@ -4,11 +4,16 @@ 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.ext.auth.AuthConfig;
import dev.relism.flash.ext.auth.Session;
import dev.relism.flash.ext.auth.AuthMiddleware;
import dev.relism.flash.ext.auth.AuthPolicy;
import dev.relism.flash.ext.auth.Authenticated;
import dev.relism.flash.ext.auth.RolesAllowed;
import dev.relism.flash.ext.auth.ScopesAllowed;
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;
@@ -28,7 +33,8 @@ import java.util.*;
* <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>Provides {@link AuthMiddleware}, {@link OidcCredentialSource} and {@link JwtValidator}
* in the context.</li>
* <li>Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
* and {@link ScopesAllowed}.</li>
* </ol>
@@ -56,7 +62,6 @@ import java.util.*;
* }</pre>
*/
public class OidcExtension implements FlashExtension {
private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.oidc.policy");
private final OidcConfig config;
@@ -65,7 +70,8 @@ public class OidcExtension implements FlashExtension {
private OidcStateStore stateStore;
private TokenClient tokenClient;
private JwtValidator validator;
private OidcMiddleware oidcMw;
private OidcCredentialSource source;
private AuthMiddleware authMw;
public OidcExtension(OidcConfig config) {
this.config = config;
@@ -87,15 +93,15 @@ public class OidcExtension implements FlashExtension {
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);
source = new OidcCredentialSource(validator, config, meta, tokenClient);
authMw = AuthMiddleware.install(ctx, AuthConfig.builder()
.rolesClaimPath(config.rolesClaimPath())
.scopeClaimPaths(config.scopeClaimPaths())
.build(), source);
ctx.provide(OidcMiddleware.class, oidcMw);
ctx.provide(JwtValidator.class, validator);
ctx.provide(OidcCredentialSource.class, source);
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));
}
@@ -164,7 +170,7 @@ public class OidcExtension implements FlashExtension {
}
Map<String, Object> claims = mergeClaims(tokens);
OidcSession session = new OidcSession(
Session session = OidcCredentialSource.newSession(
UUID.randomUUID().toString(),
tokens.accessToken(), tokens.idToken(), tokens.refreshToken(),
Instant.now().plusSeconds(tokens.expiresIn()), claims);
@@ -178,12 +184,12 @@ public class OidcExtension implements FlashExtension {
// 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 sessionId = OidcCredentialSource.cookieValue(req, "oidc_session");
String idTokenHint = null;
if (sessionId != null) {
OidcSession session = config.sessionStore().find(sessionId).orElse(null);
if (session != null) idTokenHint = session.idToken();
Session session = config.sessionStore().find(sessionId).orElse(null);
if (session != null) idTokenHint = session.attributeAsString(OidcCredentialSource.ID_TOKEN);
config.sessionStore().delete(sessionId);
}
@@ -253,7 +259,7 @@ public class OidcExtension implements FlashExtension {
private String absoluteSelf(Request req, String uri) {
if (!uri.startsWith("/")) return uri;
return OidcMiddleware.selfOrigin(req, config.selfScheme()) + uri;
return OidcCredentialSource.selfOrigin(req, config.selfScheme()) + uri;
}
private static String enc(String v) {
@@ -299,12 +305,12 @@ public class OidcExtension implements FlashExtension {
OpenApiOperationContribution.Builder out =
OpenApiOperationContribution.builder();
List<String> operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
List<String> operationScopes = AuthPolicy.openApiScopesFor(handlerClass);
if (operationScopes != null) {
out.security(config.schemeName(), operationScopes);
}
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass);
if (policy == null || policy.optionalAuth()) return out.build();
out.response(401, OpenApiResponseContribution.of("Authentication required"));
@@ -0,0 +1,43 @@
package dev.relism.flash.ext.oidc;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* What stayed behind when authorization moved to {@code flash-ext-auth-core}: reading a bearer
* token off the wire, and the RFC 6750 challenges this source answers with. The matching of
* claims those credentials produce is {@code ClaimMatchingTest}'s job now.
*/
class OidcCredentialSourceTest {
private static OidcCredentialSource source() {
return new OidcCredentialSource(null, OidcConfig
.builder("https://idp.example.com", "client", "secret", "/auth/callback")
.build(), null, null);
}
@Test
void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() {
assertEquals("abc.def.ghi", OidcCredentialSource.extractBearerToken("Bearer abc.def.ghi"));
assertEquals("abc", OidcCredentialSource.extractBearerToken(" bearer abc "));
assertNull(OidcCredentialSource.extractBearerToken("Basic Zm9vOmJhcg=="));
assertNull(OidcCredentialSource.extractBearerToken("Bearer"));
}
@Test
void bearerChallenge_containsRealmAndRfcErrors() {
OidcCredentialSource src = source();
String basic = src.bearerChallenge();
String invalid = src.invalidTokenChallenge();
String insufficient = src.insufficientScopeChallenge(new String[]{"orders:read", "payments:write"});
assertTrue(basic.startsWith("Bearer realm=\""));
assertTrue(invalid.contains("error=\"invalid_token\""));
assertTrue(insufficient.contains("error=\"insufficient_scope\""));
assertTrue(insufficient.contains("scope=\"orders:read payments:write\""));
}
}
@@ -4,6 +4,10 @@ import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.ext.openapi.OpenApiContributor;
import dev.relism.flash.ext.auth.AuthPolicy;
import dev.relism.flash.ext.auth.Authenticated;
import dev.relism.flash.ext.auth.RolesAllowed;
import dev.relism.flash.ext.auth.ScopesAllowed;
import dev.relism.flash.extension.FlashContext;
import org.junit.jupiter.api.Test;
@@ -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-auth-oidc`.
## 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**: optional, policy-driven OAuth2 via `flash-ext-auth-oidc` — 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.
@@ -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-auth-oidc` already handles its own internal JSON needs (`json-smart` 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.
@@ -1,7 +1,7 @@
# 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
`flash-ext-auth-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.
+28 -10
View File
@@ -7,32 +7,50 @@ Allowed Client Scopes configuration `scopes_supported` needs to actually work.
## `McpSecurity`
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-oidc` being
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-auth-oidc` being
installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExtension.routes()`:
| Policy | `flash-ext-oidc` installed | `flash-ext-oidc` absent |
| Policy | `flash-ext-auth-oidc` installed | `flash-ext-auth-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 |
### Guarding `/mcp` without OAuth2
`McpSecurity` only ever answers "is `flash-ext-auth-oidc` installed". An app that authenticates
some other way sets `McpSecurity.NONE` and supplies its own guard:
```java
McpConfig.builder("my-server")
.toolsPackage("com.example.mcp")
.security(McpSecurity.NONE)
.middleware(myAuthMiddleware.protect())
.build();
```
`McpConfig.middleware(...)` runs after the transport guards and after whatever `McpSecurity`
resolved to, so it composes with OAuth2 protection rather than replacing it — the same hook is how
you add rate limiting, audit logging or tracing to the endpoint. It never satisfies `REQUIRED`,
which still asks for a real authorization server.
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.
## Why `flash-ext-oidc` is an *optional* Maven dependency, concretely
## Why `flash-ext-auth-oidc` is an *optional* Maven dependency, concretely
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
`flash-ext-mcp` don't get `flash-ext-auth-oidc` pulled in automatically unless they add it themselves.
Within `flash-ext-mcp` itself, `flash-ext-auth-oidc`'s classes are on the compile/test classpath as
normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in
source.
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
evaluated — if `flash-ext-auth-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
@@ -45,7 +63,7 @@ When oidc is available and `security() != NONE`, `McpOidcIntegration` (an isolat
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)`
1. The MCP route is wrapped with `flash-ext-auth-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.
@@ -114,7 +132,7 @@ unaffected — this parameter is additive and MCP-specific.
## Per-tool `@RolesAllowed`/`@ScopesAllowed`
`McpTool` subclasses can carry `flash-ext-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
`McpTool` subclasses can carry `flash-ext-auth-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
```java
@Tool(name = "delete_route", description = "Delete a route")
@@ -124,7 +142,7 @@ public class DeleteRouteTool extends McpTool {
}
```
This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
This does **not** reuse `flash-ext-auth-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,
@@ -156,7 +174,7 @@ at `app.start()`.
## The `HttpException` safety net
`flash-ext-oidc`'s middleware throws `HttpException.unauthorized()`/`forbidden()` on auth
`flash-ext-auth-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
+1 -1
View File
@@ -19,7 +19,7 @@
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId>
<artifactId>flash-ext-auth-oidc</artifactId>
<optional>true</optional>
</dependency>
<dependency>
@@ -10,7 +10,7 @@ import java.util.function.Supplier;
*
* <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
* flash-ext-auth-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}.
@@ -1,5 +1,7 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.routing.Middleware;
import java.util.ArrayList;
import java.util.List;
@@ -28,6 +30,7 @@ public final class McpConfig {
private final String authorizationServerIssuer;
private final List<String> allowedOrigins;
private final List<String> scopesSupported;
private final List<Middleware> middleware;
private McpConfig(Builder b) {
this.name = b.name;
@@ -40,6 +43,7 @@ public final class McpConfig {
this.authorizationServerIssuer = b.authorizationServerIssuer;
this.allowedOrigins = List.copyOf(b.allowedOrigins);
this.scopesSupported = List.copyOf(b.scopesSupported);
this.middleware = List.copyOf(b.middleware);
}
String name() { return name; }
@@ -52,6 +56,7 @@ public final class McpConfig {
String authorizationServerIssuer() { return authorizationServerIssuer; }
List<String> allowedOrigins() { return allowedOrigins; }
List<String> scopesSupported() { return scopesSupported; }
List<Middleware> middleware() { return middleware; }
public static Builder builder(String name) { return new Builder(name); }
@@ -66,6 +71,7 @@ public final class McpConfig {
private String authorizationServerIssuer;
private final List<String> allowedOrigins = new ArrayList<>();
private final List<String> scopesSupported = new ArrayList<>();
private final List<Middleware> middleware = new ArrayList<>();
private Builder(String name) {
if (name == null || name.isBlank())
@@ -91,7 +97,7 @@ public final class McpConfig {
/**
* 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
* {@code flash-ext-auth-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
@@ -102,7 +108,7 @@ public final class McpConfig {
/**
* 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
* — when {@code flash-ext-auth-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).
*/
@@ -128,6 +134,22 @@ public final class McpConfig {
*/
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
/**
* Middleware to run on the MCP route, in the order given, after the transport guards and
* after whatever {@link McpSecurity} resolved to. Rate limiting, audit logging, tracing —
* anything that is routine on every other Flash route and had no way in here.
*
* <p>It runs on an authenticated request when OAuth2 protection is active, and is the only
* thing standing in front of the endpoint when it is not: {@link McpSecurity#NONE} plus a
* middleware of your own is how an app that authenticates some other way guards
* {@code /mcp}. It never satisfies {@link McpSecurity#REQUIRED}, which still asks for a
* real authorization server.
*/
public Builder middleware(Middleware... middleware) {
this.middleware.addAll(List.of(middleware));
return this;
}
public McpConfig build() {
if (toolsPackage == null || toolsPackage.isBlank())
throw new IllegalStateException(
@@ -25,7 +25,7 @@ import java.util.List;
* .build()))
* .start();
*
* // With flash-ext-oidc as the OAuth2 resource server — zero extra config: issuer, canonical
* // With flash-ext-auth-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)
@@ -65,10 +65,11 @@ public class McpExtension implements FlashExtension {
secured == null ? null : secured.rolesClaimPath());
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
List<Middleware> chain = new ArrayList<>(3);
List<Middleware> chain = new ArrayList<>(3 + config.middleware().size());
chain.add(McpTransportGuards.httpExceptionGuard());
chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
if (secured != null) chain.add(secured.security());
chain.addAll(config.middleware());
app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; },
chain.toArray(Middleware[]::new));
@@ -83,20 +84,20 @@ public class McpExtension implements FlashExtension {
try {
resolved = McpOidcIntegration.resolve(ctx, config);
} catch (NoClassDefFoundError e) {
resolved = null; // flash-ext-oidc not on the classpath at all
resolved = null; // flash-ext-auth-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() +
"McpSecurity.REQUIRED but flash-ext-auth-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.",
"flash-ext-auth-oidc is not installed and McpSecurity.AUTO degrades to unprotected. " +
"Install flash-ext-auth-oidc or set McpSecurity.REQUIRED to make this a hard failure instead.",
config.name());
return null;
}
@@ -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 — same reasoning {@code flash-ext-auth-oidc} 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,11 +1,12 @@
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.ext.auth.AuthMiddleware;
import dev.relism.flash.ext.auth.Authenticated;
import dev.relism.flash.ext.auth.Claims;
import dev.relism.flash.ext.auth.ClaimsHolder;
import dev.relism.flash.ext.auth.RolesAllowed;
import dev.relism.flash.ext.auth.ScopesAllowed;
import dev.relism.flash.ext.oidc.OidcCredentialSource;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.Request;
@@ -19,22 +20,22 @@ import java.util.function.Function;
import java.util.function.Supplier;
/**
* Lazy, isolated bridge to {@code flash-ext-oidc}.
* Lazy, isolated bridge to {@code flash-ext-auth-oidc} and {@code flash-ext-auth-core}.
*
* <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
* OAuth2) when {@code flash-ext-auth-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
* <p>Zero-config by design: when {@code flash-ext-auth-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.
* the installed {@link OidcCredentialSource}, 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.
*/
@@ -51,20 +52,25 @@ final class McpOidcIntegration {
/** 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;
// Deliberately keyed on the OIDC source and not on AuthMiddleware: McpSecurity means
// "a real OAuth2 authorization server is protecting this endpoint", and an app that
// authenticates some other way must not satisfy REQUIRED by accident.
Optional<OidcCredentialSource> oidc = ctx.find(OidcCredentialSource.class);
Optional<AuthMiddleware> auth = ctx.find(AuthMiddleware.class);
if (oidc.isEmpty() || auth.isEmpty()) return null;
OidcMiddleware oidcMw = oidc.get();
OidcCredentialSource source = oidc.get();
AuthMiddleware authMw = auth.get();
String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
String issuer = config.authorizationServerIssuer() != null
? config.authorizationServerIssuer() : oidcMw.issuer();
? config.authorizationServerIssuer() : source.issuer();
Function<Request, String> resourceId = req -> config.resourceIdentifier() != null
? config.resourceIdentifier()
: OidcMiddleware.selfOrigin(req, oidcMw.selfScheme()) + config.rootPath();
: OidcCredentialSource.selfOrigin(req, source.selfScheme()) + config.rootPath();
Middleware protect = oidcMw.protect(resourceMetadataPath);
Middleware protect = authMw.withSource(source.withResourceMetadata(resourceMetadataPath)).protect();
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
return new Resolved(secured, issuer, oidcMw.rolesClaimPath(), resourceId);
return new Resolved(secured, issuer, authMw.rolesClaimPath(), resourceId);
}
/**
@@ -73,7 +79,7 @@ final class McpOidcIntegration {
*/
private static Middleware audienceGuard(Function<Request, String> resourceIdentifier) {
return next -> (req, res) -> {
Map<String, Object> claims = ClaimsHolder.get();
Map<String, Object> claims = ClaimsHolder.map();
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 " +
@@ -100,7 +106,7 @@ final class McpOidcIntegration {
* 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.
* nothing beyond what {@link Claims#hasRole}/{@link Claims#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
@@ -117,7 +123,7 @@ final class McpOidcIntegration {
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 " +
"@ScopesAllowed, but this MCP server has no active OAuth2 protection — flash-ext-auth-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() + ".");
@@ -136,7 +142,7 @@ final class McpOidcIntegration {
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
Supplier<String> check = () -> {
OidcUser user = ClaimsHolder.user();
Claims user = ClaimsHolder.current();
if (user == null) return "not authenticated";
if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles))
return "missing required role (any of: " + String.join(", ", requiredRoles) + ")";
@@ -147,12 +153,12 @@ final class McpOidcIntegration {
return new McpAuthPolicy(check);
}
private static boolean hasAnyRole(OidcUser user, String claimPath, String[] roles) {
private static boolean hasAnyRole(Claims 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) {
private static boolean hasScopes(Claims user, String[] scopes, ScopesAllowed.Match match) {
if (match == ScopesAllowed.Match.ALL) {
for (String scope : scopes) if (!user.hasScope(scope)) return false;
return true;
@@ -175,7 +175,7 @@ final class McpRegistry {
/**
* Isolated the same way {@link McpOidcIntegration#resolve} is — {@code
* NoClassDefFoundError} here means {@code flash-ext-oidc} genuinely isn't on the runtime
* NoClassDefFoundError} here means {@code flash-ext-auth-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
@@ -2,16 +2,16 @@ 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)}).
* {@code flash-ext-auth-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
*/
public enum McpSecurity {
/** Fail fast at boot if {@code flash-ext-oidc} is not installed — never expose an unprotected MCP endpoint. */
/** Fail fast at boot if {@code flash-ext-auth-oidc} is not installed — never expose an unprotected MCP endpoint. */
REQUIRED,
/** Protect the endpoint if {@code flash-ext-oidc} is installed; otherwise run unprotected and log a warning. */
/** Protect the endpoint if {@code flash-ext-auth-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. */
/** Never protect the endpoint, even if {@code flash-ext-auth-oidc} is installed elsewhere in the app. */
NONE
}
@@ -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-auth-oidc}'s middleware) 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
@@ -26,7 +26,7 @@ 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.
* framework. Exercises {@code flash-ext-auth-oidc}'s actual discovery + JWKS + JWT validation path.
*/
final class FakeOidcProvider implements AutoCloseable {
@@ -63,7 +63,7 @@ final class FakeOidcProvider implements AutoCloseable {
/**
* 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
* matching {@link dev.relism.flash.ext.auth.Claims#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.
*/
@@ -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);
}
}
@@ -16,7 +16,7 @@ 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}
* Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-auth-oidc}
* installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
* tokens — plus the fail-fast/degrade behavior when oidc is absent.
*
@@ -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.Authenticated;
import dev.relism.flash.ext.auth.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. */
@@ -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.RolesAllowed;
import dev.relism.flash.ext.auth.RolesAllowed;
@Tool(name = "admin_only", description = "Only callable with the admin role")
@RolesAllowed("admin")
@@ -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.auth.ScopesAllowed;
@Tool(name = "write_only", description = "Only callable with the write scope")
@ScopesAllowed("write")
@@ -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,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,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,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,72 +0,0 @@
package dev.relism.flash.ext.oidc;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class OidcMiddlewareAuthzTest {
private static OidcMiddleware middleware(String rolesPath, String scopePaths) {
OidcConfig cfg = OidcConfig.builder("https://idp.example.com", "client", "secret", "/auth/callback")
.rolesClaimPath(rolesPath)
.scopeClaimPaths(scopePaths)
.build();
return new OidcMiddleware(null, cfg, null, null);
}
@Test
void rolesAllowed_readsConfiguredNestedClaimPath() {
OidcMiddleware mw = middleware("realm_access.roles", "scope,scp");
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("user", "admin")));
assertTrue(mw.rolesAllowed(claims, new String[]{"admin"}));
assertFalse(mw.rolesAllowed(claims, new String[]{"ops"}));
}
@Test
void scopesAllowed_all_requiresEveryScope() {
OidcMiddleware mw = middleware("roles", "scope,scp");
Map<String, Object> claims = Map.of("scope", "openid profile orders:read");
assertTrue(mw.scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL));
assertFalse(mw.scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL));
}
@Test
void scopesAllowed_any_acceptsAnyConfiguredScopeSource() {
OidcMiddleware mw = middleware("roles", "scope,scp,permissions.scopes");
Map<String, Object> claims = Map.of(
"scp", List.of("payments:write"),
"permissions", Map.of("scopes", "orders:approve")
);
assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve", "orders:read"}, ScopesAllowed.Match.ANY));
assertTrue(mw.scopesAllowed(claims, new String[]{"payments:write"}, ScopesAllowed.Match.ANY));
assertFalse(mw.scopesAllowed(claims, new String[]{"unknown"}, ScopesAllowed.Match.ANY));
}
@Test
void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() {
assertEquals("abc.def.ghi", OidcMiddleware.extractBearerToken("Bearer abc.def.ghi"));
assertEquals("abc", OidcMiddleware.extractBearerToken(" bearer abc "));
assertNull(OidcMiddleware.extractBearerToken("Basic Zm9vOmJhcg=="));
assertNull(OidcMiddleware.extractBearerToken("Bearer"));
}
@Test
void bearerChallenge_containsRealmAndRfcErrors() {
OidcMiddleware mw = middleware("roles", "scope,scp");
String basic = mw.bearerChallenge();
String invalid = mw.invalidTokenChallenge();
String insufficient = mw.insufficientScopeChallenge(new String[]{"orders:read", "payments:write"});
assertTrue(basic.startsWith("Bearer realm=\""));
assertTrue(invalid.contains("error=\"invalid_token\""));
assertTrue(insufficient.contains("error=\"insufficient_scope\""));
assertTrue(insufficient.contains("scope=\"orders:read payments:write\""));
}
}
+1 -1
View File
@@ -123,7 +123,7 @@ Merge policy:
## OIDC interop
When `flash-ext-oidc` is installed, OpenAPI integrates automatically:
When `flash-ext-auth-oidc` is installed, OpenAPI integrates automatically:
- security scheme under `components.securitySchemes`
- per-operation `security`
+7 -1
View File
@@ -16,7 +16,8 @@
<modules>
<module>flash-ext-jackson</module>
<module>flash-ext-openapi</module>
<module>flash-ext-oidc</module>
<module>flash-ext-auth-core</module>
<module>flash-ext-auth-oidc</module>
<module>flash-ext-routeviewer</module>
<module>flash-ext-view-core</module>
<module>flash-ext-view-jte</module>
@@ -45,6 +46,11 @@
<artifactId>flash-ext-scheduler</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-auth-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-cache-core</artifactId>
+1 -1
View File
@@ -106,7 +106,7 @@
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId>
<artifactId>flash-ext-auth-oidc</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>