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:
@@ -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>
|
||||
+43
@@ -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); }
|
||||
}
|
||||
}
|
||||
+280
@@ -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);
|
||||
}
|
||||
}
|
||||
+98
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
+230
@@ -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 == ',';
|
||||
}
|
||||
}
|
||||
+64
@@ -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;
|
||||
}
|
||||
}
|
||||
+48
@@ -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; }
|
||||
}
|
||||
+31
@@ -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();
|
||||
}
|
||||
+45
@@ -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
|
||||
}
|
||||
}
|
||||
+94
@@ -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));
|
||||
}
|
||||
}
|
||||
+209
@@ -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));
|
||||
}
|
||||
}
|
||||
+47
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user