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.
diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java
deleted file mode 100644
index 6df58af..0000000
--- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java
+++ /dev/null
@@ -1,43 +0,0 @@
-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.
- *
- * 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); }
- }
-}
diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java
deleted file mode 100644
index 2c4a104..0000000
--- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java
+++ /dev/null
@@ -1,309 +0,0 @@
-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}.
- *
- *
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.
- *
- *
{@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"));
- * }
- */
-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.
- *
- * 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 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.
- *
- * {@code
- * app.get("/", handler, auth.optional());
- * // Inside handler: ClaimsHolder.current() is non-null iff the caller is signed in.
- * }
- */
- public Middleware optional() {
- return next -> (req, res) -> {
- Map 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 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 claims, AuthPolicy policy, Response res) {
- checkRoles(claims, policy.requiredRoles());
- checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res);
- }
-
- private void checkRoles(Map claims, String[] required) {
- if (required.length == 0) return;
- if (rolesAllowed(claims, required)) return;
- throw HttpException.forbidden();
- }
-
- private void checkScopes(Map claims, String[] required, ScopesAllowed.Match match,
- Response res) {
- if (required.length == 0) return;
- if (scopesAllowed(claims, required, match)) return;
- String challenge = source.insufficientScopeChallenge(required);
- if (challenge != null) res.header("WWW-Authenticate", challenge);
- throw HttpException.forbidden();
- }
-
- // -- Claim matching -------------------------------------------------------
-
- boolean rolesAllowed(Map claims, String[] required) {
- Object actual = valueAtPath(claims, roleClaimPathParts);
- if (actual == null) return false;
- for (String role : required) {
- if (containsToken(actual, role)) return true;
- }
- return false;
- }
-
- boolean scopesAllowed(Map claims, String[] required, ScopesAllowed.Match match) {
- if (match == ScopesAllowed.Match.ALL) {
- for (String scope : required) {
- if (!hasScope(claims, scope)) return false;
- }
- return true;
- }
- for (String scope : required) {
- if (hasScope(claims, scope)) return true;
- }
- return false;
- }
-
- private boolean hasScope(Map claims, String scope) {
- for (String[] pathParts : scopeClaimPathParts) {
- Object value = valueAtPath(claims, pathParts);
- if (value != null && containsToken(value, scope)) return true;
- }
- return false;
- }
-
- private static Object valueAtPath(Map claims, String[] pathParts) {
- Object current = claims;
- for (String part : pathParts) {
- if (!(current instanceof Map, ?> map)) return null;
- current = map.get(part);
- if (current == null) return null;
- }
- return current;
- }
-
- private static boolean containsToken(Object source, String token) {
- if (source instanceof String s) return containsDelimitedToken(s, token);
- if (source instanceof List> list) {
- for (Object item : list) {
- if (item == null) continue;
- if (tokenEquals(item.toString(), token)) return true;
- }
- return false;
- }
- if (source instanceof Object[] arr) {
- for (Object item : arr) {
- if (item == null) continue;
- if (tokenEquals(item.toString(), token)) return true;
- }
- return false;
- }
- return tokenEquals(source.toString(), token);
- }
-
- private static boolean containsDelimitedToken(String value, String token) {
- int len = value.length();
- int i = 0;
- while (i < len) {
- while (i < len && isScopeDelimiter(value.charAt(i))) i++;
- int start = i;
- while (i < len && !isScopeDelimiter(value.charAt(i))) i++;
- int end = i;
- if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) {
- return true;
- }
- }
- return false;
- }
-
- private static boolean tokenEquals(String value, String token) {
- int start = 0;
- int end = value.length();
- while (start < end && Character.isWhitespace(value.charAt(start))) start++;
- while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--;
- return end - start == token.length() && value.regionMatches(start, token, 0, token.length());
- }
-
- private static boolean isScopeDelimiter(char c) {
- return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
- }
-
- private static String[] splitClaimPath(String path) {
- if (path == null || path.isBlank()) {
- throw new IllegalStateException("Claim path cannot be blank");
- }
- List parts = new ArrayList<>(4);
- int start = 0;
- int len = path.length();
- for (int i = 0; i <= len; i++) {
- if (i == len || path.charAt(i) == '.') {
- String p = path.substring(start, i).trim();
- if (!p.isEmpty()) parts.add(p);
- start = i + 1;
- }
- }
- if (parts.isEmpty()) {
- throw new IllegalStateException("Claim path cannot be blank");
- }
- return parts.toArray(String[]::new);
- }
-
- private static String[][] splitClaimPaths(String paths) {
- String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths;
- List out = new ArrayList<>(4);
- int start = 0;
- int len = source.length();
- for (int i = 0; i <= len; i++) {
- if (i == len || source.charAt(i) == ',') {
- String raw = source.substring(start, i).trim();
- if (!raw.isEmpty()) out.add(splitClaimPath(raw));
- start = i + 1;
- }
- }
- if (out.isEmpty()) {
- return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") };
- }
- return out.toArray(String[][]::new);
- }
-}
diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java
deleted file mode 100644
index 8cdb741..0000000
--- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java
+++ /dev/null
@@ -1,98 +0,0 @@
-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 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 normalized = new LinkedHashSet<>(values.length);
- for (String raw : values) {
- if (raw == null) continue;
- String trimmed = raw.trim();
- if (!trimmed.isEmpty()) normalized.add(trimmed);
- }
- if (normalized.isEmpty())
- throw new IllegalStateException("@" + annotation + " requires at least one non-empty value");
-
- return normalized.toArray(String[]::new);
- }
-}
diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Authenticated.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Authenticated.java
deleted file mode 100644
index 7ef6faa..0000000
--- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Authenticated.java
+++ /dev/null
@@ -1,40 +0,0 @@
-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.
- *
- * For role-based access use {@link RolesAllowed} instead (it implies authentication).
- *
- *
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.
- *
- *
{@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 { ... }
- * }
- */
-@Retention(RetentionPolicy.RUNTIME)
-@Target(ElementType.TYPE)
-public @interface Authenticated {
- /**
- * When {@code true} the middleware never rejects unauthenticated requests — it only
- * populates {@link ClaimsHolder} when valid credentials are present.
- * Defaults to {@code false} (hard authentication required).
- */
- boolean optional() default false;
-}
diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java
deleted file mode 100644
index bc71286..0000000
--- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java
+++ /dev/null
@@ -1,230 +0,0 @@
-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()}.
- *
- * The accessors name claim keys, 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)}.
- *
- *
{@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());
- * }
- */
-public final class Claims {
-
- private final Map claims;
-
- Claims(Map 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.
- *
- * Example paths:
- *
- * - {@code "realm_access.roles"} — Keycloak realm roles
- * - {@code "resource_access.my-client.roles"} — Keycloak client roles
- * - {@code "groups"} — Authelia / generic IdPs
- *
- *
- * @return list of role strings, or an empty list if the path doesn't exist
- */
- @SuppressWarnings("unchecked")
- public List roles(String claimPath) {
- String[] parts = claimPath.split("\\.");
- Object current = claims;
- for (String part : parts) {
- if (!(current instanceof Map, ?> m)) return List.of();
- current = m.get(part);
- }
- if (current instanceof List> list)
- return list.stream().map(Object::toString).toList();
- return List.of();
- }
-
- /** Returns {@code true} if the user holds {@code role} at the given claim path. */
- public boolean hasRole(String claimPath, String role) {
- return roles(claimPath).contains(role);
- }
-
- // -- Scopes ---------------------------------------------------------------
-
- /**
- * Resolves scopes using the conventional fallback order:
- * {@code scope} then {@code scp}. Supports both space-separated string and list forms.
- */
- public List scopes() {
- return scopes("scope,scp");
- }
-
- /**
- * Resolves scopes from comma-separated claim paths (example: {@code "scope,scp,permissions.scopes"}).
- */
- public List scopes(String claimPaths) {
- List out = new ArrayList<>();
- for (String[] path : splitClaimPaths(claimPaths)) {
- Object value = valueAtPath(path);
- if (value == null) continue;
- if (value instanceof String s) {
- appendDelimitedTokens(out, s);
- continue;
- }
- if (value instanceof List> list) {
- for (Object item : list) {
- if (item == null) continue;
- String token = item.toString().trim();
- if (!token.isEmpty()) out.add(token);
- }
- continue;
- }
- String token = value.toString().trim();
- if (!token.isEmpty()) out.add(token);
- }
- return out.isEmpty() ? List.of() : List.copyOf(out);
- }
-
- /** Returns {@code true} if the user has {@code scope}, searching default claim paths {@code scope,scp}. */
- public boolean hasScope(String scope) {
- return hasScope("scope,scp", scope);
- }
-
- /** Returns {@code true} if the user has {@code scope} in any of {@code claimPaths}. */
- public boolean hasScope(String claimPaths, String scope) {
- if (scope == null || scope.isBlank()) return false;
- String target = scope.trim();
- for (String[] path : splitClaimPaths(claimPaths)) {
- Object value = valueAtPath(path);
- if (value == null) continue;
- if (value instanceof String s && containsDelimitedToken(s, target)) return true;
- if (value instanceof List> list) {
- for (Object item : list) {
- if (item == null) continue;
- if (target.equals(item.toString().trim())) return true;
- }
- continue;
- }
- if (target.equals(value.toString().trim())) return true;
- }
- return false;
- }
-
- // ── Arbitrary claim access ─────────────────────────────────────────────
-
- /**
- * Returns the value of any claim, cast to {@code T}.
- *
- * @throws ClassCastException if the stored value is not assignable to {@code type}
- */
- public T claim(String key, Class type) {
- return type.cast(claims.get(key));
- }
-
- /** Returns the raw claim value, or {@code null} if absent. */
- public Object claim(String key) { return claims.get(key); }
-
- /** Escape hatch — returns the full unmodified claims map. */
- public Map claims() { return claims; }
-
- // ── Internals ─────────────────────────────────────────────────────────
-
- private String str(String key) {
- Object v = claims.get(key);
- return v != null ? v.toString() : null;
- }
-
- private Object valueAtPath(String[] path) {
- Object current = claims;
- for (String part : path) {
- if (!(current instanceof Map, ?> m)) return null;
- current = m.get(part);
- if (current == null) return null;
- }
- return current;
- }
-
- private static String[][] splitClaimPaths(String claimPaths) {
- String source = (claimPaths == null || claimPaths.isBlank()) ? "scope,scp" : claimPaths;
- List out = new ArrayList<>(4);
- int start = 0;
- int len = source.length();
- for (int i = 0; i <= len; i++) {
- if (i == len || source.charAt(i) == ',') {
- String raw = source.substring(start, i).trim();
- if (!raw.isEmpty()) out.add(splitPath(raw));
- start = i + 1;
- }
- }
- return out.isEmpty() ? new String[][]{ splitPath("scope"), splitPath("scp") } : out.toArray(String[][]::new);
- }
-
- private static String[] splitPath(String path) {
- List out = new ArrayList<>(4);
- int start = 0;
- int len = path.length();
- for (int i = 0; i <= len; i++) {
- if (i == len || path.charAt(i) == '.') {
- String raw = path.substring(start, i).trim();
- if (!raw.isEmpty()) out.add(raw);
- start = i + 1;
- }
- }
- return out.isEmpty() ? new String[]{ path } : out.toArray(String[]::new);
- }
-
- private static void appendDelimitedTokens(List target, String source) {
- int len = source.length();
- int i = 0;
- while (i < len) {
- while (i < len && isDelimiter(source.charAt(i))) i++;
- int start = i;
- while (i < len && !isDelimiter(source.charAt(i))) i++;
- if (i > start) target.add(source.substring(start, i));
- }
- }
-
- private static boolean containsDelimitedToken(String source, String token) {
- int len = source.length();
- int i = 0;
- while (i < len) {
- while (i < len && isDelimiter(source.charAt(i))) i++;
- int start = i;
- while (i < len && !isDelimiter(source.charAt(i))) i++;
- int end = i;
- if (end > start && end - start == token.length() && source.regionMatches(start, token, 0, token.length())) {
- return true;
- }
- }
- return false;
- }
-
- private static boolean isDelimiter(char c) {
- return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
- }
-}
diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java
deleted file mode 100644
index a8cf400..0000000
--- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java
+++ /dev/null
@@ -1,64 +0,0 @@
-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.
- *
- * Safe with virtual threads: each request gets its own, so a {@link ThreadLocal} is naturally
- * isolated per request.
- *
- *
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.
- *
- *
{@code
- * // Inside any handler behind @Authenticated or @RolesAllowed:
- * Claims caller = ClaimsHolder.current();
- * String email = caller.email();
- * List roles = caller.roles("realm_access.roles");
- *
- * // Raw escape hatch:
- * Map all = ClaimsHolder.map();
- * }
- */
-public final class ClaimsHolder {
-
- private static final ThreadLocal