refactor(ext-auth): generic sessions, shared annotation wiring, rename to flash-ext-auth-oidc

AuthMiddleware.install(ctx, config, source) now owns the annotation processor and
the flash.auth.policy key, so a second credential source gets annotation-driven
authorization without copying the wiring. The key is public: an extension that
contributes middleware can order itself around authentication.

OidcSession becomes Session in auth-core, carrying claims, an expiry and an opaque
attribute map. OpenID Connect keeps its access, id and refresh tokens in that map
under its own keys, so renewal stays its business and core has no OAuth2 vocabulary
in it. isAccessTokenExpired() becomes isExpired(), with the 30s eager-renewal
window it always had and now a test for it.

flash-ext-oidc is renamed flash-ext-auth-oidc, matching cache-core/cache-caffeine
and data-core/data-hibernate.
This commit is contained in:
Zakaria El Orche
2026-09-10 19:06:15 +00:00
parent c5be6ac7b8
commit 9d39e24ccb
45 changed files with 271 additions and 166 deletions
@@ -5,6 +5,8 @@ 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;
@@ -28,6 +30,13 @@ import java.util.Map;
*/
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;
@@ -40,6 +49,26 @@ public class AuthMiddleware {
this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths());
}
/**
* Builds the middleware for {@code source}, publishes it in the context and registers the
* annotation processor that mounts {@link Authenticated}, {@link RolesAllowed} and
* {@link ScopesAllowed} on scanned handlers.
*
* <p>Every extension that contributes a {@link CredentialSource} calls this rather than
* repeating the wiring — the processor and the {@link #POLICY} key belong to one place.
*/
public static AuthMiddleware install(FlashContext ctx, AuthConfig config, CredentialSource source) {
AuthMiddleware middleware = new AuthMiddleware(config, source);
ctx.provide(AuthMiddleware.class, middleware);
ctx.addAnnotationProcessor(handlerClass -> {
AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass);
return policy != null
? List.of(MiddlewareNode.of(POLICY, middleware.authorize(policy)))
: List.of();
});
return middleware;
}
// -- Public API -----------------------------------------------------------
/** The single configured claim path used by every transport for role checks. */
@@ -0,0 +1,20 @@
package dev.relism.flash.ext.auth;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe in-memory {@link SessionStore}.
*
* <p>Sessions are lost on restart and not shared across instances. For
* production deployments with multiple nodes or restart-persistence requirements,
* supply another implementation to whichever {@link CredentialSource} owns the session.
*/
public final class InMemorySessionStore implements SessionStore {
private final ConcurrentHashMap<String, Session> store = new ConcurrentHashMap<>();
@Override public void save(Session s) { store.put(s.id(), s); }
@Override public Optional<Session> find(String id) { return Optional.ofNullable(store.get(id)); }
@Override public void delete(String id) { store.remove(id); }
}
@@ -0,0 +1,53 @@
package dev.relism.flash.ext.auth;
import java.time.Instant;
import java.util.Map;
/**
* A signed-in caller's server-side session — saved in a {@link SessionStore} and looked up by a
* cookie on every request.
*
* <p>Immutable: renewing one produces a new instance that replaces the old under the same
* {@link #id()}.
*
* <p>{@link #attributes()} is whatever the {@link CredentialSource} needs to keep alongside the
* claims and nothing this module interprets — OpenID Connect stores its access, id and refresh
* tokens there so that renewal is its business rather than core's.
*/
public final class Session {
/** Renew this far before the real expiry, so a session cannot lapse mid-request. */
private static final long EAGER_RENEWAL_SECONDS = 30;
private final String id;
private final Map<String, Object> claims;
private final Instant expiresAt;
private final Map<String, Object> attributes;
public Session(String id, Map<String, Object> claims, Instant expiresAt,
Map<String, Object> attributes) {
this.id = id;
this.claims = Map.copyOf(claims);
this.expiresAt = expiresAt;
this.attributes = attributes == null ? Map.of() : Map.copyOf(attributes);
}
/** True once the session is within {@value #EAGER_RENEWAL_SECONDS} seconds of expiring. */
public boolean isExpired() {
return Instant.now().isAfter(expiresAt.minusSeconds(EAGER_RENEWAL_SECONDS));
}
/** One attribute, or {@code null} when the source never stored it. */
public Object attribute(String key) { return attributes.get(key); }
/** One attribute as a String, or {@code null}. */
public String attributeAsString(String key) {
Object v = attributes.get(key);
return v != null ? v.toString() : null;
}
public String id() { return id; }
public Map<String, Object> claims() { return claims; }
public Instant expiresAt() { return expiresAt; }
public Map<String, Object> attributes() { return attributes; }
}
@@ -0,0 +1,13 @@
package dev.relism.flash.ext.auth;
import java.util.Optional;
/**
* Where {@link Session}s live between requests. {@link InMemorySessionStore} is the default;
* supply another for Redis, JDBC, or anything that survives a restart or spans instances.
*/
public interface SessionStore {
void save(Session session);
Optional<Session> find(String sessionId);
void delete(String sessionId);
}
@@ -0,0 +1,58 @@
package dev.relism.flash.ext.auth;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The two things a session has to get right: it reports itself expired early enough that it
* cannot lapse midway through a request, and it hands back what a credential source stored on it
* without interpreting any of it.
*/
class SessionTest {
private static Session at(Instant expiry, Map<String, Object> attributes) {
return new Session("s1", Map.of("sub", "u1"), expiry, attributes);
}
@Test
void aSessionIsExpiredWellBeforeItsDeadline() {
// The eager window is what stops a session from lapsing between the check and the handler.
assertFalse(at(Instant.now().plusSeconds(120), Map.of()).isExpired());
assertTrue(at(Instant.now().plusSeconds(10), Map.of()).isExpired());
assertTrue(at(Instant.now().minusSeconds(1), Map.of()).isExpired());
}
@Test
void attributesAreReturnedUninterpreted() {
Session session = at(Instant.now().plusSeconds(60), Map.of("oidc.id_token", "abc", "n", 7));
assertEquals("abc", session.attributeAsString("oidc.id_token"));
assertEquals("7", session.attributeAsString("n"));
assertEquals(7, session.attribute("n"));
assertNull(session.attributeAsString("absent"));
}
@Test
void aSessionWithoutAttributesIsUsableRatherThanNull() {
assertNull(at(Instant.now().plusSeconds(60), null).attributeAsString("anything"));
}
@Test
void claimsAndAttributesAreCopiedAndImmutable() {
Map<String, Object> mutable = new HashMap<>(Map.of("k", "v"));
Session session = at(Instant.now().plusSeconds(60), mutable);
mutable.put("k", "changed");
assertEquals("v", session.attributeAsString("k"));
assertThrows(UnsupportedOperationException.class, () -> session.attributes().put("x", "y"));
assertThrows(UnsupportedOperationException.class, () -> session.claims().put("x", "y"));
}
}