refactor(ext-oidc): replace auth modules with security extensions

This commit is contained in:
Zakaria El Orche
2026-09-16 15:54:19 +00:00
parent 017c2443f4
commit e0795299fc
126 changed files with 2944 additions and 5130 deletions
@@ -0,0 +1,88 @@
# flash-ext-security-core
Authentication and authorization for Flash, independent of any credential. Mechanisms —
[`-oidc`](../../flash-ext-security-oidc/docs/README.md), [`-apikey`](../../flash-ext-security-apikey/docs/README.md),
[`-form`](../../flash-ext-security-form/docs/README.md), or your own — register into one chain;
this module owns everything downstream of "who is the caller".
```java
app.install(new SecurityExtension()
.users(principal -> users.findOrProvision(principal)) // optional: principal → your user
.roles((identity, role, on) -> members.has(identity.user(User.class), role, on.get("project"))))
.install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)));
```
## The model
| Type | Role |
|---|---|
| `AuthenticationMechanism` | reads one kind of credential: returns a `Principal`, `null` (not mine), or throws `AuthenticationFailedException` (mine, invalid) |
| `Principal` | who the mechanism proved the caller to be — typed per mechanism (`OidcPrincipal`, `ApiKeyPrincipal`, …) |
| `SecurityIdentity` | the current caller: `principal(OidcPrincipal.class)`, `user(User.class)`, `hasRole`, `hasScope` |
| `UserResolver` | principal → application user, resolved lazily, once per request |
| `RoleResolver` | whether a caller holds a role, optionally on a resource |
| `AuthenticationEntryPoint` | the answer to a request that needs a caller and carries no credential |
Mechanisms never write the response. That is what keeps the one mistake that matters impossible to
make: a credential that was presented and rejected is always a 401, never a redirect into a sign-in
page an API client cannot parse.
## Annotations
On a handler or an MCP tool class:
| | |
|---|---|
| `@Authenticated` | any authenticated caller |
| `@PermitAll` | anyone; a caller who authenticates is still identified, one who fails is anonymous |
| `@RolesAllowed(value, on)` | any of the roles; `on` names the path/query parameters (tool arguments on MCP) identifying the resource |
| `@ScopesAllowed(value)` | every one of the credential's scopes |
`@RolesAllowed(value = "MANAGER", on = "project")` on `/projects/{project}/keys` asks the
`RoleResolver` whether the caller is a manager *of that project*. A handler declaring roles with no
`RoleResolver` configured fails the boot. Policies compile once; checking one allocates nothing.
## The chain
Mechanisms are tried in registration order, then the session cookie. The first to return a
principal wins. When none does:
- a browser (`Accept: text/html`) is redirected to the only login method, or to `loginPage` when there are several;
- anything else gets `401` with every mechanism's challenge in `WWW-Authenticate`.
`entryPoint(...)` replaces that, e.g. to pick an identity provider from the user's email domain.
## Sessions
`signIn(req, res, principal[, expiresAt])` stores the principal under a `flash_session` cookie;
`POST /auth/logout` ends it and follows `Principal.logoutUrl()`. An expired session is handed to the
`SessionRefresher` registered for its principal type, or ended. `InMemorySessionStore` is the
default; `sessions(...)` swaps it for one that survives a restart or spans instances.
`GET /auth/methods` lists every registered `LoginMethod` for a client to render.
## OpenAPI
With `flash-ext-openapi` present, every registered mechanism's `SecurityScheme` is published, and
every protected operation lists them as alternatives, with its 401 and — for roles or scopes — its
403 and what it requires. Nothing to write per mechanism.
## Writing a mechanism
```java
security.mechanism(new AuthenticationMechanism() {
public Principal authenticate(Request req) {
String key = req.header("X-Key");
if (key == null) return null; // not mine
Principal p = keys.get(key);
if (p == null) throw new AuthenticationFailedException(null); // mine, and invalid
return p;
}
public SecurityScheme scheme() { return SecurityScheme.bearer("key", "opaque"); }
});
```
## Testing
[`flash-ext-security-test`](../../flash-ext-security-test/docs/README.md) authenticates requests as
any principal without an identity provider.
@@ -0,0 +1,35 @@
<?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-security-core</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-openapi</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,12 @@
package dev.relism.flash.ext.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/** The handler or MCP tool requires an authenticated caller. */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@java.lang.annotation.Target(ElementType.TYPE)
public @interface Authenticated {}
@@ -0,0 +1,11 @@
package dev.relism.flash.ext.security;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
/** Answers a request that needs a caller and carries no credential at all. */
@FunctionalInterface
public interface AuthenticationEntryPoint {
Object commence(Request req, Response res) throws Exception;
}
@@ -0,0 +1,24 @@
package dev.relism.flash.ext.security;
import dev.relism.flash.exceptions.HttpException;
/** A credential was presented and rejected. Stackless: turning away forged tokens stays cheap. */
public final class AuthenticationFailedException extends HttpException {
private final String challenge;
/** @param challenge the {@code WWW-Authenticate} value to answer with, or {@code null} */
public AuthenticationFailedException(String challenge) {
super(401, "Unauthorized");
this.challenge = challenge;
}
public String challenge() {
return challenge;
}
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
}
@@ -0,0 +1,21 @@
package dev.relism.flash.ext.security;
import dev.relism.flash.models.Request;
/**
* Reads one kind of credential off a request. A mechanism never writes the response: an anonymous
* request is answered by the {@link AuthenticationEntryPoint}, a rejected one by the 401 its
* {@link AuthenticationFailedException} carries.
*/
public interface AuthenticationMechanism {
/**
* The caller, or {@code null} when the request carries no credential of this kind.
*
* @throws AuthenticationFailedException the request carries one, and it is invalid
*/
Principal authenticate(Request req);
/** How OpenAPI documents the credential and a 401 challenges for it; {@code null} for neither. */
default SecurityScheme scheme() { return null; }
}
@@ -0,0 +1,26 @@
package dev.relism.flash.ext.security;
import java.util.concurrent.ConcurrentHashMap;
/** One instance's sessions, lost on restart. Expired ones are swept whenever a session is saved. */
public final class InMemorySessionStore implements SessionStore {
private final ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>();
@Override
public void save(Session session) {
long now = System.currentTimeMillis();
sessions.values().removeIf(s -> s.expiresAt().toEpochMilli() <= now);
sessions.put(session.id(), session);
}
@Override
public Session find(String id) {
return sessions.get(id);
}
@Override
public void delete(String id) {
sessions.remove(id);
}
}
@@ -0,0 +1,12 @@
package dev.relism.flash.ext.security;
/** A way to sign in, listed at {@code GET /auth/methods} for a client to offer. */
public record LoginMethod(String id, String name, String url, Kind kind) {
public enum Kind {
/** A browser navigates to {@code url} and comes back signed in — OpenID Connect, for instance. */
REDIRECT,
/** A client posts {@code username} and {@code password} to {@code url}. */
FORM
}
}
@@ -0,0 +1,15 @@
package dev.relism.flash.ext.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* Anyone may call the handler. A caller who authenticates is still identified; one whose credential
* is rejected is treated as anonymous rather than refused.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@java.lang.annotation.Target(ElementType.TYPE)
public @interface PermitAll {}
@@ -0,0 +1,20 @@
package dev.relism.flash.ext.security;
/**
* Who an {@link AuthenticationMechanism} proved the caller to be. Each mechanism has its own type;
* {@link SecurityIdentity#principal(Class)} reads it back.
*/
public interface Principal {
/** Unique within the mechanism that produced it. */
String name();
/** Whether the credential grants {@code scope}. One that carries no scopes grants every scope. */
default boolean hasScope(String scope) { return true; }
/** Whether the credential was issued for {@code audience} (RFC 8707). One bound to no audience was. */
default boolean hasAudience(String audience) { return true; }
/** Where signing out sends the browser; {@code null} for the application root. */
default String logoutUrl() { return null; }
}
@@ -0,0 +1,11 @@
package dev.relism.flash.ext.security;
/**
* Whether a caller holds a role — read from a token, a database, anywhere. {@code on} identifies
* the resource for roles held per resource rather than globally.
*/
@FunctionalInterface
public interface RoleResolver {
boolean hasRole(SecurityIdentity identity, String role, Target on);
}
@@ -0,0 +1,24 @@
package dev.relism.flash.ext.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* The caller must hold at least one of these roles, as decided by the configured
* {@link RoleResolver}. Implies {@link Authenticated}.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@java.lang.annotation.Target(ElementType.TYPE)
public @interface RolesAllowed {
String[] value();
/**
* Names of the path or query parameters (tool arguments on MCP) that identify the resource the
* role is held on — {@code on = "project"} checks the role on {@code /projects/{project}}.
*/
String[] on() default {};
}
@@ -0,0 +1,15 @@
package dev.relism.flash.ext.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/** The caller's credential must grant every one of these scopes. Implies {@link Authenticated}. */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@java.lang.annotation.Target(ElementType.TYPE)
public @interface ScopesAllowed {
String[] value();
}
@@ -0,0 +1,321 @@
package dev.relism.flash.ext.security;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.openapi.OpenApiContributor;
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.http.ContentType;
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 dev.relism.fpr.core.ByteView;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Flash security: the authentication chain, the policies security annotations declare, sessions,
* and the {@code /auth/logout} and {@code /auth/methods} routes. Mechanisms register through
* {@link #mechanism}, directly or from their own extensions, and are tried in registration order
* before the session cookie.
*
* <pre>{@code
* app.install(new SecurityExtension().users(users).roles(roles))
* .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)));
* }</pre>
*/
public class SecurityExtension implements FlashExtension {
/** The node security annotations mount under, for middleware that must run before or after it. */
public static final MiddlewareKey POLICY = MiddlewareKey.of("flash.security.policy");
private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
private static final String COOKIE = "flash_session";
private static final SecureRandom RANDOM = new SecureRandom();
private final Map<Class<?>, SessionRefresher> refreshers = new ConcurrentHashMap<>();
private volatile AuthenticationMechanism[] mechanisms = {};
private volatile SecurityScheme[] schemes = {};
private volatile LoginMethod[] loginMethods = {};
private volatile String challenges;
private volatile String methodsJson = "[]";
UserResolver<?> users = principal -> principal;
RoleResolver roles;
private AuthenticationEntryPoint entryPoint = this::commence;
private SessionStore sessions = new InMemorySessionStore();
private Duration sessionTimeout = Duration.ofHours(12);
private String loginPage = "/login";
// -- Configuration --------------------------------------------------------
/** Resolves {@link SecurityIdentity#user} — default: the principal itself. */
public SecurityExtension users(UserResolver<?> users) {
this.users = users;
return this;
}
/** Required by {@link RolesAllowed}; a handler that declares roles without one fails the boot. */
public SecurityExtension roles(RoleResolver roles) {
this.roles = roles;
return this;
}
/** Replaces the default: a browser is redirected to sign in, anything else gets 401 with every challenge. */
public SecurityExtension entryPoint(AuthenticationEntryPoint entryPoint) {
this.entryPoint = entryPoint;
return this;
}
public SecurityExtension sessions(SessionStore sessions) {
this.sessions = sessions;
return this;
}
public SecurityExtension sessionTimeout(Duration sessionTimeout) {
this.sessionTimeout = sessionTimeout;
return this;
}
/** Where a browser signs in, unless the only {@link LoginMethod} is a redirect it can follow directly. */
public SecurityExtension loginPage(String loginPage) {
this.loginPage = loginPage;
return this;
}
// -- Registration (boot time) ---------------------------------------------
public synchronized SecurityExtension mechanism(AuthenticationMechanism mechanism) {
mechanisms = append(mechanisms, mechanism);
return mechanism.scheme() == null ? this : scheme(mechanism.scheme());
}
/** Documents and challenges for a credential beyond the one {@link AuthenticationMechanism#scheme()} names. */
public synchronized SecurityExtension scheme(SecurityScheme scheme) {
schemes = append(schemes, scheme);
challenges = challenges == null ? scheme.challenge() : challenges + ", " + scheme.challenge();
return this;
}
public synchronized SecurityExtension loginMethod(LoginMethod method) {
loginMethods = append(loginMethods, method);
StringBuilder json = new StringBuilder("[");
for (LoginMethod m : loginMethods) {
if (json.length() > 1) json.append(',');
json.append("{\"id\":\"").append(m.id()).append("\",\"name\":\"").append(m.name())
.append("\",\"url\":\"").append(m.url()).append("\",\"kind\":\"").append(m.kind().name().toLowerCase()).append("\"}");
}
methodsJson = json.append(']').toString();
return this;
}
public SecurityExtension refresher(Class<? extends Principal> type, SessionRefresher refresher) {
refreshers.put(type, refresher);
return this;
}
/** The schemes of every registered mechanism, in registration order. */
public List<SecurityScheme> schemes() {
return List.of(schemes);
}
// -- Runtime --------------------------------------------------------------
/**
* The caller, or {@code null} when no mechanism recognises a credential.
*
* @throws AuthenticationFailedException a mechanism recognised one and rejected it
*/
public SecurityIdentity authenticate(Request req) {
for (AuthenticationMechanism mechanism : mechanisms) {
Principal principal = mechanism.authenticate(req);
if (principal != null) return new SecurityIdentity(principal, this, req);
}
Principal principal = sessionPrincipal(req);
return principal == null ? null : new SecurityIdentity(principal, this, req);
}
/**
* The policy {@code type}'s annotations declare, checked against this configuration — declaring
* roles without a {@link RoleResolver} fails here, at boot. {@code null} for no annotations.
*/
public SecurityPolicy policy(Class<?> type) {
SecurityPolicy policy = SecurityPolicy.of(type);
if (policy != null && policy.requiresRoles() && roles == null) {
throw new IllegalStateException(type.getName() + " declares @RolesAllowed, but no RoleResolver is configured — SecurityExtension.roles(...)");
}
return policy;
}
public Middleware enforce(SecurityPolicy policy) {
return enforce(policy, entryPoint);
}
/** {@code anonymous} answers a caller without credentials on this route instead of the configured entry point. */
public Middleware enforce(SecurityPolicy policy, AuthenticationEntryPoint anonymous) {
return next -> (req, res) -> {
SecurityIdentity identity;
try {
identity = authenticate(req);
} catch (AuthenticationFailedException rejected) {
if (policy.required) {
if (rejected.challenge() != null) res.header(WWW_AUTHENTICATE, rejected.challenge());
throw rejected;
}
identity = null;
}
if (identity == null) {
if (policy.required) return anonymous.commence(req, res);
} else if (!policy.permitsScopes(identity)) {
res.header(WWW_AUTHENTICATE, policy.scopeChallenge);
throw HttpException.forbidden();
} else if (!policy.permitsRoles(identity, policy.on.length == 0 ? Target.NONE : name -> {
String value = req.param(name);
return value != null ? value : req.query(name);
})) {
throw HttpException.forbidden();
}
SecurityIdentity.CURRENT.set(identity);
try {
return next.handle(req, res);
} finally {
SecurityIdentity.CURRENT.remove();
}
};
}
/** Starts a session for {@code principal} lasting the configured timeout. */
public void signIn(Request req, Response res, Principal principal) {
signIn(req, res, principal, Instant.now().plus(sessionTimeout));
}
public void signIn(Request req, Response res, Principal principal, Instant expiresAt) {
byte[] id = new byte[24];
RANDOM.nextBytes(id);
Session session = new Session(Base64.getUrlEncoder().withoutPadding().encodeToString(id), principal, expiresAt);
sessions.save(session);
res.header("Set-Cookie", COOKIE + "=" + session.id() + "; Path=/; HttpOnly; SameSite=Lax" + (req.origin().startsWith("https") ? "; Secure" : ""));
}
/** Ends the caller's session and returns where the browser goes next. */
public String signOut(Request req, Response res) {
String id = req.cookie(COOKIE);
Session session = id == null ? null : sessions.find(id);
if (session != null) sessions.delete(id);
res.header("Set-Cookie", COOKIE + "=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax");
String next = session == null ? null : session.principal().logoutUrl();
return next == null ? "/" : next;
}
// -- Extension ------------------------------------------------------------
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.provide(SecurityExtension.class, this);
ctx.addAnnotationProcessor(handler -> {
SecurityPolicy policy = policy(handler);
return policy == null ? List.of() : List.of(MiddlewareNode.of(POLICY, enforce(policy)));
});
app.post("/auth/logout", (req, res) -> {
res.status(303).header("Location", signOut(req, res));
return null;
});
app.get("/auth/methods", (req, res) -> {
res.type(ContentType.JSON);
return methodsJson;
});
ctx.onReady(() -> {
try {
OpenApi.register(ctx, this);
} catch (NoClassDefFoundError absent) {
// flash-ext-openapi is not on the classpath
}
});
}
private Object commence(Request req, Response res) {
LoginMethod[] methods = loginMethods;
String accept = req.header("Accept");
if (methods.length > 0 && accept != null && accept.contains("text/html")) {
String login = methods.length == 1 && methods[0].kind() == LoginMethod.Kind.REDIRECT ? methods[0].url() : loginPage;
ByteView query = req.getRequestLine().getQuery();
byte[] raw = new byte[query == null ? 0 : query.length()];
for (int i = 0; i < raw.length; i++) raw[i] = query.byteAt(i);
String target = raw.length == 0 ? req.path() : req.path() + "?" + new String(raw, StandardCharsets.UTF_8);
res.redirect(login + "?redirect=" + URLEncoder.encode(target, StandardCharsets.UTF_8));
return null;
}
if (challenges != null) res.header(WWW_AUTHENTICATE, challenges);
throw HttpException.unauthorized();
}
private Principal sessionPrincipal(Request req) {
String id = req.cookie(COOKIE);
Session session = id == null ? null : sessions.find(id);
if (session == null) return null;
if (session.expiresAt().toEpochMilli() > System.currentTimeMillis()) return session.principal();
SessionRefresher refresher = refreshers.get(session.principal().getClass());
Session renewed = refresher == null ? null : refresher.refresh(session);
if (renewed == null) {
sessions.delete(id);
return null;
}
sessions.save(renewed);
return renewed.principal();
}
private static <T> T[] append(T[] array, T element) {
T[] grown = Arrays.copyOf(array, array.length + 1);
grown[array.length] = element;
return grown;
}
/** Isolated so this extension loads without flash-ext-openapi on the classpath. */
private static final class OpenApi {
static void register(FlashContext ctx, SecurityExtension security) {
ctx.find(OpenApiContributorRegistry.class).ifPresent(registry -> registry.add(new OpenApiContributor() {
@Override
public Map<String, Object> componentContributions() {
Map<String, Object> definitions = new LinkedHashMap<>();
for (SecurityScheme scheme : security.schemes) definitions.put(scheme.name(), scheme.definition());
return definitions.isEmpty() ? Map.of() : Map.of("securitySchemes", definitions);
}
@Override
public OpenApiOperationContribution operationFor(Class<?> handler) {
SecurityPolicy policy = SecurityPolicy.of(handler);
if (policy == null || !policy.required) return OpenApiOperationContribution.empty();
OpenApiOperationContribution.Builder operation = OpenApiOperationContribution.builder();
for (SecurityScheme scheme : security.schemes) {
operation.security(scheme.name(), scheme.issuer() != null ? List.of(policy.scopes) : List.of());
}
operation.response(401, OpenApiResponseContribution.of("Authentication required"));
String roles = policy.roles.length == 0 ? null : "Requires role " + String.join(" or ", policy.roles)
+ (policy.on.length == 0 ? "" : " on " + String.join(", ", policy.on));
String scopes = policy.scopes.length == 0 ? null : "Requires scopes " + String.join(" ", policy.scopes);
if (roles != null || scopes != null) {
operation.response(403, OpenApiResponseContribution.of(
roles == null ? scopes : scopes == null ? roles : roles + "; " + scopes));
}
return operation.build();
}
}));
}
}
}
@@ -0,0 +1,65 @@
package dev.relism.flash.ext.security;
import dev.relism.flash.models.Request;
/**
* The authenticated caller of the current request: the {@link Principal} a mechanism produced, the
* application user it resolves to, and the roles and scopes it holds.
*/
public final class SecurityIdentity {
static final ThreadLocal<SecurityIdentity> CURRENT = new ThreadLocal<>();
private final Principal principal;
private final SecurityExtension security;
private final Request request;
private Object user;
SecurityIdentity(Principal principal, SecurityExtension security, Request request) {
this.principal = principal;
this.security = security;
this.request = request;
}
/** The caller of the request this thread is handling; {@code null} when it is anonymous. */
public static SecurityIdentity current() {
return CURRENT.get();
}
public Principal principal() {
return principal;
}
/**
* The request being authorized. {@link Target} carries what {@link RolesAllowed#on()} names, read
* from path and query parameters; a {@link RoleResolver} whose scope is somewhere else — a tenant
* header, say — reads it from here.
*/
public Request request() {
return request;
}
/** The principal as {@code type}, or {@code null} when another mechanism authenticated the caller. */
public <P extends Principal> P principal(Class<P> type) {
return type.isInstance(principal) ? type.cast(principal) : null;
}
/** The application user, resolved once per request by the configured {@link UserResolver}. */
public <U> U user(Class<U> type) {
if (user == null) user = security.users.resolve(principal);
return type.cast(user);
}
public boolean hasScope(String scope) {
return principal.hasScope(scope);
}
public boolean hasRole(String role) {
return hasRole(role, Target.NONE);
}
public boolean hasRole(String role, Target on) {
if (security.roles == null) throw new IllegalStateException("No RoleResolver configured — SecurityExtension.roles(...)");
return security.roles.hasRole(this, role, on);
}
}
@@ -0,0 +1,63 @@
package dev.relism.flash.ext.security;
/** What a handler's or tool's security annotations require, compiled once at boot. Checks allocate nothing. */
public final class SecurityPolicy {
/** Any authenticated caller. */
public static final SecurityPolicy AUTHENTICATED = new SecurityPolicy(true, new String[0], new String[0], new String[0]);
final boolean required;
final String[] roles;
final String[] on;
final String[] scopes;
final String scopeChallenge;
private SecurityPolicy(boolean required, String[] roles, String[] on, String[] scopes) {
this.required = required;
this.roles = roles;
this.on = on;
this.scopes = scopes;
this.scopeChallenge = "Bearer error=\"insufficient_scope\", scope=\"" + String.join(" ", scopes) + "\"";
}
/** The policy {@code type} declares, or {@code null} when it carries no security annotation. */
public static SecurityPolicy of(Class<?> type) {
boolean permitAll = type.isAnnotationPresent(PermitAll.class);
boolean authenticated = type.isAnnotationPresent(Authenticated.class);
RolesAllowed roles = type.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = type.getAnnotation(ScopesAllowed.class);
if (!permitAll && !authenticated && roles == null && scopes == null) return null;
if (permitAll && (authenticated || roles != null || scopes != null)) {
throw new IllegalStateException("@PermitAll contradicts the other security annotations on " + type.getName());
}
return new SecurityPolicy(!permitAll,
roles == null ? AUTHENTICATED.roles : values(roles.value(), "@RolesAllowed", type),
roles == null ? AUTHENTICATED.on : roles.on(),
scopes == null ? AUTHENTICATED.scopes : values(scopes.value(), "@ScopesAllowed", type));
}
/** False only for {@link PermitAll}. */
public boolean required() {
return required;
}
public boolean requiresRoles() {
return roles.length > 0;
}
public boolean permitsScopes(SecurityIdentity identity) {
for (String scope : scopes) if (!identity.hasScope(scope)) return false;
return true;
}
public boolean permitsRoles(SecurityIdentity identity, Target target) {
if (roles.length == 0) return true;
for (String role : roles) if (identity.hasRole(role, target)) return true;
return false;
}
private static String[] values(String[] values, String annotation, Class<?> type) {
if (values.length == 0) throw new IllegalStateException(annotation + " on " + type.getName() + " names nothing");
return values;
}
}
@@ -0,0 +1,24 @@
package dev.relism.flash.ext.security;
import java.util.Map;
/**
* A credential as OpenAPI names and defines it, the {@code WWW-Authenticate} challenge an anonymous
* API call receives for it, and — for OAuth — the issuer that grants it.
*
* @param definition the OpenAPI Security Scheme Object, verbatim
* @param issuer the authorization server's issuer identifier, {@code null} for anything else
*/
public record SecurityScheme(String name, Map<String, Object> definition, String challenge, String issuer) {
public static SecurityScheme bearer(String name, String bearerFormat) {
return new SecurityScheme(name, Map.of("type", "http", "scheme", "bearer", "bearerFormat", bearerFormat),
"Bearer realm=\"" + name + "\"", null);
}
public static SecurityScheme openIdConnect(String name, String issuer) {
return new SecurityScheme(name,
Map.of("type", "openIdConnect", "openIdConnectUrl", issuer + (issuer.endsWith("/") ? "" : "/") + ".well-known/openid-configuration"),
"Bearer realm=\"" + name + "\"", issuer);
}
}
@@ -0,0 +1,6 @@
package dev.relism.flash.ext.security;
import java.time.Instant;
/** A signed-in principal, kept server-side under the id its cookie carries. */
public record Session(String id, Principal principal, Instant expiresAt) {}
@@ -0,0 +1,8 @@
package dev.relism.flash.ext.security;
/** Renews an expired session — with a refresh token, typically. {@code null} ends it instead. */
@FunctionalInterface
public interface SessionRefresher {
Session refresh(Session expired);
}
@@ -0,0 +1,12 @@
package dev.relism.flash.ext.security;
/** Where sessions live. {@link InMemorySessionStore} unless one that survives restarts is configured. */
public interface SessionStore {
void save(Session session);
/** The session, or {@code null}. */
Session find(String id);
void delete(String id);
}
@@ -0,0 +1,15 @@
package dev.relism.flash.ext.security;
/**
* The resource a role is checked on: the values {@link RolesAllowed#on()} names, read from path
* and query parameters on HTTP and from tool arguments on MCP.
*/
@FunctionalInterface
public interface Target {
/** A role checked on nothing in particular. */
Target NONE = name -> null;
/** The value called {@code name}, or {@code null} when the call does not carry one. */
String get(String name);
}
@@ -0,0 +1,8 @@
package dev.relism.flash.ext.security;
/** The application user a verified principal belongs to — typically found, or provisioned, by issuer and subject. */
@FunctionalInterface
public interface UserResolver<U> {
U resolve(Principal principal);
}
@@ -0,0 +1,132 @@
package dev.relism.flash.ext.security;
import dev.relism.flash.ext.openapi.OpenApiExtension;
import dev.relism.flash.models.Request;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.time.Instant;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
class SecurityExtensionTest {
/** {@code Authorization: Key <name>[ <scope>...]}; {@code Key !} is a presented, invalid credential. */
record KeyPrincipal(String name, Set<String> scopes) implements Principal {
@Override public boolean hasScope(String scope) { return scopes.contains(scope); }
}
static final AuthenticationMechanism KEY = new AuthenticationMechanism() {
@Override
public Principal authenticate(Request req) {
String header = req.header("Authorization");
if (header == null || !header.startsWith("Key ")) return null;
String[] parts = header.substring(4).split(" ");
if (parts[0].equals("!")) throw new AuthenticationFailedException("Key error=\"invalid_token\"");
return new KeyPrincipal(parts[0], Set.of(java.util.Arrays.copyOfRange(parts, 1, parts.length)));
}
@Override
public SecurityScheme scheme() {
return SecurityScheme.bearer("key", "opaque");
}
};
static final SecurityExtension security = new SecurityExtension()
.roles((identity, role, on) -> identity.principal().name().equals(role + "@" + on.get("project")))
.mechanism(KEY)
.loginMethod(new LoginMethod("key", "Key", "/auth/key/login", LoginMethod.Kind.REDIRECT))
.refresher(KeyPrincipal.class, expired -> new Session(expired.id(), expired.principal(), Instant.now().plusSeconds(60)));
@RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash
.install(security)
.install(new OpenApiExtension("/openapi", "test", "1"))
.get("/me", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED))
.post("/login", (req, res) -> {
security.signIn(req, res, new KeyPrincipal("carol", Set.of()), Instant.now().minusSeconds(1));
return "signed in";
})
.scan("dev.relism.flash.ext.security.fixtures"));
@Test
void aValidCredentialIdentifiesTheCaller() {
app.request().header("Authorization", "Key alice").get("/me").expectStatus(200).expectBody("alice");
}
/** Rejected is never anonymous: a browser presenting a bad credential must not be sent to sign in. */
@Test
void aRejectedCredentialIs401WithItsOwnChallengeEvenForABrowser() {
app.request().header("Authorization", "Key !").header("Accept", "text/html").get("/me")
.expectStatus(401)
.expectHeader("WWW-Authenticate", "Key error=\"invalid_token\"");
}
@Test
void anApiCallWithoutCredentialsIsChallengedForEveryMechanism() {
app.get("/me").expectStatus(401).expectHeader("WWW-Authenticate", "Bearer realm=\"key\"");
}
@Test
void aBrowserWithoutCredentialsGoesStraightToTheOnlyLoginMethod() {
app.request().header("Accept", "text/html").get("/me?tab=keys")
.expectStatus(302)
.expectHeader("Location", "/auth/key/login?redirect=%2Fme%3Ftab%3Dkeys");
}
@Test
void permitAllIdentifiesWhoeverAuthenticatesAndToleratesEveryoneElse() {
app.request().header("Authorization", "Key bob").get("/open").expectBody("bob");
app.request().header("Authorization", "Key !").get("/open").expectStatus(200).expectBody("anonymous");
app.get("/open").expectBody("anonymous");
}
@Test
void rolesAreCheckedOnTheResourceThePathNames() {
app.request().header("Authorization", "Key MANAGER@42").get("/projects/42").expectStatus(200);
app.request().header("Authorization", "Key MANAGER@42").get("/projects/7").expectStatus(403);
}
@Test
void aMissingScopeIs403WithAnInsufficientScopeChallenge() {
app.request().header("Authorization", "Key dave write").post("/write").expectStatus(200);
app.request().header("Authorization", "Key dave").post("/write")
.expectStatus(403)
.expectHeader("WWW-Authenticate", "Bearer error=\"insufficient_scope\", scope=\"write\"");
}
/** The session is signed in already expired, so reaching /me proves the refresher ran. */
@Test
void aSessionIsRefreshedWhenExpiredAndEndedBySigningOut() {
String cookie = app.request().post("/login").expectStatus(200).header("Set-Cookie");
String session = cookie.substring(0, cookie.indexOf(';'));
app.request().header("Cookie", session).get("/me").expectStatus(200).expectBody("carol");
app.request().header("Cookie", session).post("/auth/logout").expectStatus(303).expectHeader("Location", "/");
app.request().header("Cookie", session).get("/me").expectStatus(401);
}
@Test
void loginMethodsAreListed() {
app.get("/auth/methods").expectStatus(200).expectBody("[{\"id\":\"key\",\"name\":\"Key\",\"url\":\"/auth/key/login\",\"kind\":\"redirect\"}]");
}
@Test
void openApiDocumentsEachSchemeAndWhatEachOperationRequires() {
app.get("/openapi.json").expectStatus(200)
.expectBodyContains("\"securitySchemes\"")
.expectBodyContains("\"bearerFormat\":\"opaque\"")
.expectBodyContains("Requires role MANAGER on project")
.expectBodyContains("Requires scopes write");
}
@Test
void declaringRolesWithoutAResolverFailsTheBoot() {
FlashTest broken = FlashTest.of(flash -> flash
.install(new SecurityExtension())
.scan("dev.relism.flash.ext.security.fixtures"));
assertThrows(Exception.class, () -> broken.get("/open"));
}
}
@@ -0,0 +1,44 @@
package dev.relism.flash.ext.security;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SecurityPolicyTest {
static class Unannotated {}
@PermitAll @RolesAllowed("ADMIN")
static class Contradictory {}
@RolesAllowed({})
static class NoRoles {}
@RolesAllowed("ADMIN") @ScopesAllowed("write")
static class RolesAndScopes {}
@Test
void anUnannotatedTypeHasNoPolicy() {
assertNull(SecurityPolicy.of(Unannotated.class));
}
@Test
void contradictionsAndEmptyRequirementsFailAtCompileTime() {
assertThrows(IllegalStateException.class, () -> SecurityPolicy.of(Contradictory.class));
assertThrows(IllegalStateException.class, () -> SecurityPolicy.of(NoRoles.class));
}
@Test
void rolesAndScopesBothRequireAuthentication() {
SecurityPolicy policy = SecurityPolicy.of(RolesAndScopes.class);
assertTrue(policy.required());
assertTrue(policy.requiresRoles());
assertFalse(SecurityPolicy.of(OpenAccess.class).required());
}
@PermitAll
static class OpenAccess {}
}
@@ -0,0 +1,18 @@
package dev.relism.flash.ext.security.fixtures;
import dev.relism.flash.ext.security.PermitAll;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.GET;
@GET("/open")
@PermitAll
public final class OpenHandler extends RequestHandler {
@Override
public Object handle(Request req, Response res) {
SecurityIdentity identity = SecurityIdentity.current();
return identity == null ? "anonymous" : identity.principal().name();
}
}
@@ -0,0 +1,19 @@
package dev.relism.flash.ext.security.fixtures;
import dev.relism.flash.ext.openapi.ApiOperation;
import dev.relism.flash.ext.security.RolesAllowed;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.GET;
@GET("/projects/{project}")
@ApiOperation(summary = "fixture")
@RolesAllowed(value = "MANAGER", on = "project")
public final class ProjectHandler extends RequestHandler {
@Override
public Object handle(Request req, Response res) {
return SecurityIdentity.current().principal().name();
}
}
@@ -0,0 +1,18 @@
package dev.relism.flash.ext.security.fixtures;
import dev.relism.flash.ext.openapi.ApiOperation;
import dev.relism.flash.ext.security.ScopesAllowed;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.POST;
@POST("/write")
@ApiOperation(summary = "fixture")
@ScopesAllowed("write")
public final class WriteHandler extends RequestHandler {
@Override
public Object handle(Request req, Response res) {
return "written";
}
}