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().
This commit is contained in:
Zakaria El Orche
2026-09-10 19:00:39 +00:00
parent ea00182c7c
commit c5be6ac7b8
27 changed files with 966 additions and 815 deletions
@@ -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,280 @@
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 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 {
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());
}
// -- 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);
}
}
@@ -0,0 +1,98 @@
package dev.relism.flash.ext.auth;
import java.util.LinkedHashSet;
import java.util.List;
/**
* Compiled authorization policy derived from handler annotations at mount time.
* Immutable and allocation-free on the request hot path.
*/
public final class AuthPolicy {
private static final String[] EMPTY = new String[0];
private static final AuthPolicy AUTH_REQUIRED = new AuthPolicy(
false, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
private static final AuthPolicy AUTH_OPTIONAL = new AuthPolicy(
true, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
private final boolean optionalAuth;
private final String[] requiredRoles;
private final String[] requiredScopes;
private final ScopesAllowed.Match scopeMatch;
private AuthPolicy(boolean optionalAuth,
String[] requiredRoles,
String[] requiredScopes,
ScopesAllowed.Match scopeMatch) {
this.optionalAuth = optionalAuth;
this.requiredRoles = requiredRoles;
this.requiredScopes = requiredScopes;
this.scopeMatch = scopeMatch;
}
public static AuthPolicy authenticated() { return AUTH_REQUIRED; }
public static AuthPolicy optional() { return AUTH_OPTIONAL; }
public static AuthPolicy rolesAny(String... roles) {
return new AuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL);
}
public static AuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) {
return new AuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match);
}
public static AuthPolicy compileFromAnnotations(Class<?> handlerClass) {
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
if (auth == null && roles == null && scopes == null) return null;
boolean optionalAuth = auth != null && auth.optional();
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : EMPTY;
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : EMPTY;
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
if (optionalAuth && (requiredRoles.length > 0 || requiredScopes.length > 0)) {
throw new IllegalStateException("@Authenticated(optional = true) cannot be combined with @RolesAllowed/@ScopesAllowed on "
+ handlerClass.getName());
}
return new AuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
}
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);
if (auth == null && roles == null && scopes == null) return null;
if (scopes == null) return List.of();
return List.of(normalizeRequired("ScopesAllowed", scopes.value()));
}
public boolean optionalAuth() { return optionalAuth; }
public String[] requiredRoles() { return requiredRoles; }
public String[] requiredScopes() { return requiredScopes; }
public ScopesAllowed.Match scopeMatch() { return scopeMatch; }
private static String[] normalizeRequired(String annotation, String[] values) {
if (values == null || values.length == 0)
throw new IllegalStateException("@" + annotation + " requires at least one value");
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
for (String raw : values) {
if (raw == null) continue;
String trimmed = raw.trim();
if (!trimmed.isEmpty()) normalized.add(trimmed);
}
if (normalized.isEmpty())
throw new IllegalStateException("@" + annotation + " requires at least one non-empty value");
return normalized.toArray(String[]::new);
}
}
@@ -0,0 +1,40 @@
package dev.relism.flash.ext.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a handler as requiring 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 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 — 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 signed in:
* @Route(method = HttpMethod.GET, path = "/")
* @Authenticated(optional = true)
* public class HomePage extends HtmlHandler { ... }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Authenticated {
/**
* When {@code true} the middleware never rejects unauthenticated requests — it only
* populates {@link ClaimsHolder} when valid credentials are present.
* Defaults to {@code false} (hard authentication required).
*/
boolean optional() default false;
}
@@ -0,0 +1,230 @@
package dev.relism.flash.ext.auth;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;
/**
* A typed view over one request's claims — whatever the {@link CredentialSource} that
* authenticated it produced. Obtained from {@link ClaimsHolder#current()}.
*
* <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
* app.get("/api/whoami", (req, res) -> {
* 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 Claims {
private final Map<String, Object> claims;
Claims(Map<String, Object> claims) {
this.claims = claims;
}
// ── Common claims ─────────────────────────────────────────────────────────
/** Subject identifier — the stable, unique id of the caller. */
public String sub() { return str("sub"); }
/** User's email address ({@code email} claim). */
public String email() { return str("email"); }
/** Human-readable username ({@code preferred_username} claim). */
public String username() { return str("preferred_username"); }
/** Full display name ({@code name} claim). */
public String name() { return str("name"); }
// ── Roles ────────────────────────────────────────────────────────────────
/**
* Extracts the roles list by traversing a dot-separated claim path.
*
* <p>Example paths:
* <ul>
* <li>{@code "realm_access.roles"} — Keycloak realm roles</li>
* <li>{@code "resource_access.my-client.roles"} — Keycloak client roles</li>
* <li>{@code "groups"} — Authelia / generic IdPs</li>
* </ul>
*
* @return list of role strings, or an empty list if the path doesn't exist
*/
@SuppressWarnings("unchecked")
public List<String> roles(String claimPath) {
String[] parts = claimPath.split("\\.");
Object current = claims;
for (String part : parts) {
if (!(current instanceof Map<?, ?> m)) return List.of();
current = m.get(part);
}
if (current instanceof List<?> list)
return list.stream().map(Object::toString).toList();
return List.of();
}
/** Returns {@code true} if the user holds {@code role} at the given claim path. */
public boolean hasRole(String claimPath, String role) {
return roles(claimPath).contains(role);
}
// -- Scopes ---------------------------------------------------------------
/**
* Resolves scopes using the conventional fallback order:
* {@code scope} then {@code scp}. Supports both space-separated string and list forms.
*/
public List<String> scopes() {
return scopes("scope,scp");
}
/**
* Resolves scopes from comma-separated claim paths (example: {@code "scope,scp,permissions.scopes"}).
*/
public List<String> scopes(String claimPaths) {
List<String> out = new ArrayList<>();
for (String[] path : splitClaimPaths(claimPaths)) {
Object value = valueAtPath(path);
if (value == null) continue;
if (value instanceof String s) {
appendDelimitedTokens(out, s);
continue;
}
if (value instanceof List<?> list) {
for (Object item : list) {
if (item == null) continue;
String token = item.toString().trim();
if (!token.isEmpty()) out.add(token);
}
continue;
}
String token = value.toString().trim();
if (!token.isEmpty()) out.add(token);
}
return out.isEmpty() ? List.of() : List.copyOf(out);
}
/** Returns {@code true} if the user has {@code scope}, searching default claim paths {@code scope,scp}. */
public boolean hasScope(String scope) {
return hasScope("scope,scp", scope);
}
/** Returns {@code true} if the user has {@code scope} in any of {@code claimPaths}. */
public boolean hasScope(String claimPaths, String scope) {
if (scope == null || scope.isBlank()) return false;
String target = scope.trim();
for (String[] path : splitClaimPaths(claimPaths)) {
Object value = valueAtPath(path);
if (value == null) continue;
if (value instanceof String s && containsDelimitedToken(s, target)) return true;
if (value instanceof List<?> list) {
for (Object item : list) {
if (item == null) continue;
if (target.equals(item.toString().trim())) return true;
}
continue;
}
if (target.equals(value.toString().trim())) return true;
}
return false;
}
// ── Arbitrary claim access ─────────────────────────────────────────────
/**
* Returns the value of any claim, cast to {@code T}.
*
* @throws ClassCastException if the stored value is not assignable to {@code type}
*/
public <T> T claim(String key, Class<T> type) {
return type.cast(claims.get(key));
}
/** Returns the raw claim value, or {@code null} if absent. */
public Object claim(String key) { return claims.get(key); }
/** Escape hatch — returns the full unmodified claims map. */
public Map<String, Object> claims() { return claims; }
// ── Internals ─────────────────────────────────────────────────────────
private String str(String key) {
Object v = claims.get(key);
return v != null ? v.toString() : null;
}
private Object valueAtPath(String[] path) {
Object current = claims;
for (String part : path) {
if (!(current instanceof Map<?, ?> m)) return null;
current = m.get(part);
if (current == null) return null;
}
return current;
}
private static String[][] splitClaimPaths(String claimPaths) {
String source = (claimPaths == null || claimPaths.isBlank()) ? "scope,scp" : claimPaths;
List<String[]> out = new ArrayList<>(4);
int start = 0;
int len = source.length();
for (int i = 0; i <= len; i++) {
if (i == len || source.charAt(i) == ',') {
String raw = source.substring(start, i).trim();
if (!raw.isEmpty()) out.add(splitPath(raw));
start = i + 1;
}
}
return out.isEmpty() ? new String[][]{ splitPath("scope"), splitPath("scp") } : out.toArray(String[][]::new);
}
private static String[] splitPath(String path) {
List<String> out = new ArrayList<>(4);
int start = 0;
int len = path.length();
for (int i = 0; i <= len; i++) {
if (i == len || path.charAt(i) == '.') {
String raw = path.substring(start, i).trim();
if (!raw.isEmpty()) out.add(raw);
start = i + 1;
}
}
return out.isEmpty() ? new String[]{ path } : out.toArray(String[]::new);
}
private static void appendDelimitedTokens(List<String> target, String source) {
int len = source.length();
int i = 0;
while (i < len) {
while (i < len && isDelimiter(source.charAt(i))) i++;
int start = i;
while (i < len && !isDelimiter(source.charAt(i))) i++;
if (i > start) target.add(source.substring(start, i));
}
}
private static boolean containsDelimitedToken(String source, String token) {
int len = source.length();
int i = 0;
while (i < len) {
while (i < len && isDelimiter(source.charAt(i))) i++;
int start = i;
while (i < len && !isDelimiter(source.charAt(i))) i++;
int end = i;
if (end > start && end - start == token.length() && source.regionMatches(start, token, 0, token.length())) {
return true;
}
}
return false;
}
private static boolean isDelimiter(char c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
}
}
@@ -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,31 @@
package dev.relism.flash.ext.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Restricts a handler to callers 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 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 is sufficient):
* @RolesAllowed({"admin", "editor"})
* public class UpdateBlog extends JacksonHandler { ... }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface RolesAllowed {
/** One or more role names. Access is granted if the caller has any of them. */
String[] value();
}
@@ -0,0 +1,45 @@
package dev.relism.flash.ext.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Restricts a handler to callers whose credential carries the required scopes.
* Authentication is implicitly required.
*
* <p>Scopes are resolved from the configured claim paths in
* {@link AuthConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support
* both standard formats:
* <ul>
* <li>{@code scope}: space-separated string</li>
* <li>{@code scp}: string list (or string)</li>
* </ul>
*
* <pre>{@code
* @Route(method = HttpMethod.GET, path = "/api/orders")
* @ScopesAllowed("orders:read")
* public class ListOrders extends JacksonHandler { ... }
*
* @Route(method = HttpMethod.POST, path = "/api/orders")
* @ScopesAllowed(value = {"orders:write", "payments:write"}, match = ScopesAllowed.Match.ANY)
* public class CreateOrder extends JacksonHandler { ... }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ScopesAllowed {
/** Required scopes. */
String[] value();
/** Matching mode for {@link #value()}. */
Match match() default Match.ALL;
enum Match {
/** Any one required scope is sufficient. */
ANY,
/** All required scopes must be present. */
ALL
}
}
@@ -0,0 +1,94 @@
package dev.relism.flash.ext.auth;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class AuthPolicyTest {
static class PlainHandler {}
@Authenticated
static class AuthenticatedHandler {}
@Authenticated(optional = true)
static class OptionalHandler {}
@RolesAllowed({"admin", " editor ", "admin"})
static class RolesHandler {}
@ScopesAllowed(value = {"orders:write", " payments:write ", "orders:write"}, match = ScopesAllowed.Match.ANY)
static class ScopesHandler {}
@Authenticated
@RolesAllowed("admin")
@ScopesAllowed(value = {"orders:read", "payments:read"}, match = ScopesAllowed.Match.ALL)
static class CombinedHandler {}
@Authenticated(optional = true)
@ScopesAllowed("orders:read")
static class InvalidOptionalHandler {}
@Test
void compileFromAnnotations_noSecurityAnnotations_returnsNull() {
assertNull(AuthPolicy.compileFromAnnotations(PlainHandler.class));
}
@Test
void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() {
AuthPolicy policy = AuthPolicy.compileFromAnnotations(AuthenticatedHandler.class);
assertNotNull(policy);
assertFalse(policy.optionalAuth());
assertEquals(0, policy.requiredRoles().length);
assertEquals(0, policy.requiredScopes().length);
}
@Test
void compileFromAnnotations_optionalAuth_createsOptionalPolicy() {
AuthPolicy policy = AuthPolicy.compileFromAnnotations(OptionalHandler.class);
assertNotNull(policy);
assertTrue(policy.optionalAuth());
}
@Test
void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() {
AuthPolicy policy = AuthPolicy.compileFromAnnotations(CombinedHandler.class);
assertNotNull(policy);
assertFalse(policy.optionalAuth());
assertArrayEquals(new String[]{"admin"}, policy.requiredRoles());
assertArrayEquals(new String[]{"orders:read", "payments:read"}, policy.requiredScopes());
assertEquals(ScopesAllowed.Match.ALL, policy.scopeMatch());
}
@Test
void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() {
AuthPolicy policy = AuthPolicy.compileFromAnnotations(ScopesHandler.class);
assertNotNull(policy);
assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes());
assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch());
}
@Test
void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() {
assertThrows(IllegalStateException.class,
() -> AuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class));
}
@Test
void openApiScopesFor_returnsScopesWhenPresent() {
assertEquals(List.of("orders:write", "payments:write"),
AuthPolicy.openApiScopesFor(ScopesHandler.class));
}
@Test
void openApiScopesFor_rolesOnly_returnsEmptyList() {
assertEquals(List.of(), AuthPolicy.openApiScopesFor(RolesHandler.class));
}
@Test
void openApiScopesFor_noSecurity_returnsNull() {
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));
}
}
@@ -0,0 +1,47 @@
package dev.relism.flash.ext.auth;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class ClaimsScopesTest {
@Test
void scopes_readsStandardScopeString() {
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"));
assertFalse(user.hasScope("orders:write"));
}
@Test
void scopes_fallsBackToScpArray() {
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"));
}
@Test
void scopes_supportsCustomClaimPaths() {
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"));
assertFalse(user.hasScope("permissions.scopes", "x"));
}
@Test
void scopes_combinesMultipleClaimPathsInOrder() {
Claims user = new Claims(Map.of(
"scope", "openid",
"scp", List.of("profile", "orders:read")
));
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes("scope,scp"));
}
}