openApiScopesFor(Class> handlerClass) {
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
@@ -72,13 +72,13 @@ final class OidcAuthPolicy {
return List.of(normalizeRequired("ScopesAllowed", scopes.value()));
}
- boolean optionalAuth() { return optionalAuth; }
+ public boolean optionalAuth() { return optionalAuth; }
- String[] requiredRoles() { return requiredRoles; }
+ public String[] requiredRoles() { return requiredRoles; }
- String[] requiredScopes() { return requiredScopes; }
+ public String[] requiredScopes() { return requiredScopes; }
- ScopesAllowed.Match scopeMatch() { return scopeMatch; }
+ public ScopesAllowed.Match scopeMatch() { return scopeMatch; }
private static String[] normalizeRequired(String annotation, String[] values) {
if (values == null || values.length == 0)
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Authenticated.java
similarity index 67%
rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java
rename to flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Authenticated.java
index 4888202..7ef6faa 100644
--- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java
+++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Authenticated.java
@@ -1,4 +1,4 @@
-package dev.relism.flash.ext.oidc;
+package dev.relism.flash.ext.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
@@ -6,23 +6,23 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
- * Marks a handler as requiring a valid JWT. Any bearer token that passes
- * signature + expiry + issuer validation is accepted — no role check is performed.
+ * Marks a handler as requiring an authenticated caller. Any credential a registered source
+ * accepts is enough — no role or scope check is performed.
*
* For role-based access use {@link RolesAllowed} instead (it implies authentication).
*
- *
Set {@code optional = true} on public routes that personalise their response when
- * the user happens to be logged in but should remain accessible to guests. The middleware
- * will populate {@link ClaimsHolder} if credentials are present and silently skip it
- * otherwise — the request is never rejected.
+ *
Set {@code optional = true} on public routes that personalise their response when the caller
+ * happens to be signed in but should remain reachable by guests. The middleware populates
+ * {@link ClaimsHolder} when a credential is present and silently skips it otherwise — the request
+ * is never rejected.
*
*
{@code
- * // Hard auth — redirects / 401 when unauthenticated:
+ * // 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 logged in:
+ * // Soft auth — guest-friendly, ClaimsHolder populated only when signed in:
* @Route(method = HttpMethod.GET, path = "/")
* @Authenticated(optional = true)
* public class HomePage extends HtmlHandler { ... }
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java
similarity index 85%
rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java
rename to flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java
index d54f7f4..bc71286 100644
--- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java
+++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java
@@ -1,43 +1,36 @@
-package dev.relism.flash.ext.oidc;
+package dev.relism.flash.ext.auth;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;
/**
- * Type-safe view over the JWT claims stored in {@link ClaimsHolder}.
+ * A typed view over one request's claims — whatever the {@link CredentialSource} that
+ * authenticated it produced. Obtained from {@link ClaimsHolder#current()}.
*
- * Obtainable from any protected context via {@link ClaimsHolder#user()}.
- * Class-based handlers that extend the {@code SessionHandler} hierarchy already
- * have a provisioned DB user in {@code currentUser}; {@code OidcUser} complements
- * that by giving access to the raw OIDC claims when needed, and is the primary
- * API for lambda routes.
+ *
The accessors name claim keys, not a protocol: {@code sub} is RFC 7519, and
+ * {@code email}, {@code name} and {@code preferred_username} are spelled the same way by every
+ * token issuer worth integrating. A source that uses different keys exposes them through
+ * {@link #claim(String)} or {@link #roles(String)}.
*
*
{@code
- * // Lambda route (OidcMiddleware injected):
* app.get("/api/whoami", (req, res) -> {
- * OidcUser u = ClaimsHolder.user();
- * return Map.of("sub", u.sub(), "email", u.email(), "roles", u.roles("realm_access.roles"), "scopes", u.scopes());
- * }, oidcMw.protect());
- *
- * // Class-based handler (currentUser is the DB entity; oidcUser() for raw claims):
- * protected Object handleAuthenticated(Request req, Response res) throws Exception {
- * OidcUser u = oidcUser(); // same as ClaimsHolder.user()
- * return json(res, currentUser); // DB entity — provisioned from OIDC sub
- * }
+ * Claims c = ClaimsHolder.current();
+ * return Map.of("sub", c.sub(), "email", c.email(), "roles", c.roles("realm_access.roles"));
+ * }, auth.protect());
* }
*/
-public final class OidcUser {
+public final class Claims {
private final Map claims;
- OidcUser(Map claims) {
+ Claims(Map claims) {
this.claims = claims;
}
- // ── Common OIDC standard claims ───────────────────────────────────────────
+ // ── Common claims ─────────────────────────────────────────────────────────
- /** Subject identifier — unique, stable user ID issued by the provider. */
+ /** Subject identifier — the stable, unique id of the caller. */
public String sub() { return str("sub"); }
/** User's email address ({@code email} claim). */
@@ -84,7 +77,7 @@ public final class OidcUser {
// -- Scopes ---------------------------------------------------------------
/**
- * Resolves OAuth2 scopes from standard OIDC/OAuth claims using fallback order:
+ * Resolves scopes using the conventional fallback order:
* {@code scope} then {@code scp}. Supports both space-separated string and list forms.
*/
public List scopes() {
diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java
new file mode 100644
index 0000000..a8cf400
--- /dev/null
+++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java
@@ -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.
+ *
+ * Safe with virtual threads: each request gets its own, so a {@link ThreadLocal} is naturally
+ * isolated per request.
+ *
+ *
Writing is deliberately not public. A {@link CredentialSource} returns claims and the
+ * middleware publishes them, so no code outside this module can put claims on a request that did
+ * not carry them.
+ *
+ *
{@code
+ * // Inside any handler behind @Authenticated or @RolesAllowed:
+ * Claims caller = ClaimsHolder.current();
+ * String email = caller.email();
+ * List roles = caller.roles("realm_access.roles");
+ *
+ * // Raw escape hatch:
+ * Map all = ClaimsHolder.map();
+ * }
+ */
+public final class ClaimsHolder {
+
+ private static final ThreadLocal
*/
public class OidcExtension implements FlashExtension {
- private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.oidc.policy");
+ private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.auth.policy");
private final OidcConfig config;
@@ -65,7 +72,8 @@ public class OidcExtension implements FlashExtension {
private OidcStateStore stateStore;
private TokenClient tokenClient;
private JwtValidator validator;
- private OidcMiddleware oidcMw;
+ private OidcCredentialSource source;
+ private AuthMiddleware authMw;
public OidcExtension(OidcConfig config) {
this.config = config;
@@ -87,14 +95,19 @@ public class OidcExtension implements FlashExtension {
validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http);
stateStore = new OidcStateStore();
tokenClient = new TokenClient(http, config);
- oidcMw = new OidcMiddleware(validator, config, meta, tokenClient);
+ source = new OidcCredentialSource(validator, config, meta, tokenClient);
+ authMw = new AuthMiddleware(AuthConfig.builder()
+ .rolesClaimPath(config.rolesClaimPath())
+ .scopeClaimPaths(config.scopeClaimPaths())
+ .build(), source);
- ctx.provide(OidcMiddleware.class, oidcMw);
- ctx.provide(JwtValidator.class, validator);
+ ctx.provide(AuthMiddleware.class, authMw);
+ ctx.provide(OidcCredentialSource.class, source);
+ ctx.provide(JwtValidator.class, validator);
ctx.addAnnotationProcessor(handlerClass -> {
- OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
- return policy != null ? List.of(MiddlewareNode.of(POLICY, oidcMw.policyMiddleware(policy))) : List.of();
+ AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass);
+ return policy != null ? List.of(MiddlewareNode.of(POLICY, authMw.authorize(policy))) : List.of();
});
ctx.onReady(() -> registerRoutes(app, ctx));
}
@@ -178,7 +191,7 @@ public class OidcExtension implements FlashExtension {
// ── POST {prefix}/logout ──────────────────────────────────────────────
// Invalidates the local session and redirects to end_session_endpoint.
app.post(prefix + "/logout", (req, res) -> {
- String sessionId = OidcMiddleware.cookieValue(req, "oidc_session");
+ String sessionId = OidcCredentialSource.cookieValue(req, "oidc_session");
String idTokenHint = null;
if (sessionId != null) {
@@ -253,7 +266,7 @@ public class OidcExtension implements FlashExtension {
private String absoluteSelf(Request req, String uri) {
if (!uri.startsWith("/")) return uri;
- return OidcMiddleware.selfOrigin(req, config.selfScheme()) + uri;
+ return OidcCredentialSource.selfOrigin(req, config.selfScheme()) + uri;
}
private static String enc(String v) {
@@ -299,12 +312,12 @@ public class OidcExtension implements FlashExtension {
OpenApiOperationContribution.Builder out =
OpenApiOperationContribution.builder();
- List operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
+ List operationScopes = AuthPolicy.openApiScopesFor(handlerClass);
if (operationScopes != null) {
out.security(config.schemeName(), operationScopes);
}
- OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
+ AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass);
if (policy == null || policy.optionalAuth()) return out.build();
out.response(401, OpenApiResponseContribution.of("Authentication required"));
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java
deleted file mode 100644
index 7da1b7f..0000000
--- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java
+++ /dev/null
@@ -1,550 +0,0 @@
-package dev.relism.flash.ext.oidc;
-
-import dev.relism.flash.exceptions.HttpException;
-import dev.relism.flash.extension.FlashContext;
-import dev.relism.flash.models.Response;
-import dev.relism.flash.models.Request;
-import dev.relism.flash.routing.Middleware;
-
-import java.net.URLEncoder;
-import java.nio.charset.StandardCharsets;
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-
-/**
- * Request-level OIDC middleware. Exposed in the {@link FlashContext}
- * for manual use on lambda routes; injected automatically for handlers annotated with
- * {@link Authenticated}, {@link RolesAllowed} or {@link ScopesAllowed}.
- *
- * Resolution order on each request:
- *
- * - {@code Authorization: Bearer ...} header — validated via JWKS ({@link JwtValidator}).
- * - {@code oidc_session} cookie — looked up in {@link OidcSessionStore}; transparently
- * refreshed if the access token is expired.
- * - Browser clients (no {@code Accept: application/json}) → redirect to
- * {@code {routePrefix}/login?redirect={path}}.
- * - API clients → 401.
- *
- *
- * {@code
- * // Manual use on a lambda route:
- * OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
- * app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), oidc.protect());
- * app.delete("/admin/users/{id}", handler, oidc.requireRole("admin"));
- * }
- */
-public class OidcMiddleware {
-
- private static final String BEARER = "Bearer";
-
- private final JwtValidator validator;
- private final OidcConfig config;
- private final OidcProviderMetadata meta;
- private final TokenClient tokenClient;
- private final String[] roleClaimPathParts;
- private final String[][] scopeClaimPathParts;
-
- OidcMiddleware(JwtValidator validator, OidcConfig config,
- OidcProviderMetadata meta, TokenClient tokenClient) {
- this.validator = validator;
- this.config = config;
- this.meta = meta;
- this.tokenClient = tokenClient;
- 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(); }
-
- /**
- * Validates the bearer token or session cookie. Browser clients are redirected
- * to the login page on failure; API clients receive 401.
- */
- public Middleware protect() {
- return protect(null);
- }
-
- /**
- * Like {@link #protect()}, but a 401 challenge also carries {@code resource_metadata}
- * (RFC 9728 §5.1), resolved against this request's own scheme/host exactly like
- * {@link OidcExtension}'s redirect URIs. {@code resourceMetadataPath} is an absolute path
- * (e.g. {@code "/.well-known/oauth-protected-resource/mcp"}); pass {@code null} for plain
- * challenges. Used by {@code flash-ext-mcp} to make its Protected Resource Metadata
- * document discoverable straight from the {@code WWW-Authenticate} header, per the MCP
- * Authorization spec.
- */
- public Middleware protect(String resourceMetadataPath) {
- return next -> (req, res) -> {
- Map claims = resolve(req, res, resourceMetadataPath);
- if (claims == null) return null; // redirect already written
- ClaimsHolder.set(claims);
- try {
- return next.handle(req, res);
- } finally {
- ClaimsHolder.clear();
- }
- };
- }
-
- /** OIDC issuer this middleware validates tokens against — the {@code iss} claim it enforces. */
- public String issuer() { return config.issuer(); }
-
- /** Scheme used to build this app's own absolute URLs — see {@link OidcConfig#selfScheme()}. */
- public String selfScheme() { return config.selfScheme(); }
-
- /**
- * Silently populates {@link ClaimsHolder} if a valid bearer token or session cookie
- * is present, but never rejects or redirects unauthenticated requests. Use this on
- * public routes that want to personalise the response when the user happens to be
- * logged in (e.g. showing a username on a landing page).
- *
- * {@code
- * app.get("/", handler, oidc.optional());
- * // Inside handler: ClaimsHolder.user() is non-null iff the user is logged in.
- * }
- */
- public Middleware optional() {
- return next -> (req, res) -> {
- Map claims = resolveQuiet(req);
- if (claims != null) ClaimsHolder.set(claims);
- try {
- return next.handle(req, res);
- } finally {
- ClaimsHolder.clear();
- }
- };
- }
-
- /**
- * Compiled authorization policy path used by annotation-driven mounting.
- * The policy is immutable and built once at boot.
- */
- public Middleware authorize(OidcAuthPolicy policy) {
- if (policy.optionalAuth()) return optional();
- return next -> (req, res) -> {
- Map claims = resolve(req, res);
- if (claims == null) return null;
- enforcePolicy(claims, policy, res);
- ClaimsHolder.set(claims);
- try {
- return next.handle(req, res);
- } finally {
- ClaimsHolder.clear();
- }
- };
- }
-
- /**
- * Like {@link #protect()} but also enforces that the caller holds at least one
- * of the given roles (OR semantics). Roles are extracted via
- * {@link OidcConfig#rolesClaimPath()}.
- */
- public Middleware requireRole(String... roles) {
- return authorize(OidcAuthPolicy.rolesAny(roles));
- }
-
- /**
- * Requires all listed scopes to be present in the token.
- * Scopes are resolved from configured claim paths (default: {@code scope,scp}).
- */
- public Middleware requireScopes(String... scopes) {
- return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL));
- }
-
- /**
- * Requires at least one of the listed scopes to be present in the token.
- * Scopes are resolved from configured claim paths (default: {@code scope,scp}).
- */
- public Middleware requireAnyScope(String... scopes) {
- return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY));
- }
-
- // -- Package-private: AnnotationProcessor hooks ---------------------------
-
- Middleware authenticatedMiddleware() { return protect(); }
- Middleware optionalMiddleware() { return optional(); }
- Middleware rolesMiddleware(String[] required) { return requireRole(required); }
- Middleware scopesMiddleware(String[] required, ScopesAllowed.Match match) {
- return authorize(OidcAuthPolicy.scopes(required, match));
- }
- Middleware policyMiddleware(OidcAuthPolicy policy) { return authorize(policy); }
-
- // -- Internals ------------------------------------------------------------
-
- /**
- * Like {@link #resolve} but never redirects or throws — returns {@code null} silently
- * when no valid credentials are present. Used by {@link #optional()}.
- */
- private Map resolveQuiet(Request req) {
- String bearerToken = extractBearerToken(req.header("Authorization"));
- if (bearerToken != null) {
- try {
- return validator.validate(bearerToken);
- } catch (Exception ignored) {
- return null;
- }
- }
-
- String sessionId = cookieValue(req, "oidc_session");
- if (sessionId != null) {
- Optional found = config.sessionStore().find(sessionId);
- if (found.isPresent()) {
- OidcSession session = found.get();
- if (!session.isAccessTokenExpired())
- return session.claims();
- if (session.refreshToken() != null) {
- try {
- OidcSession refreshed = doRefresh(session);
- config.sessionStore().save(refreshed);
- return refreshed.claims();
- } catch (Exception ignored) { }
- }
- config.sessionStore().delete(sessionId);
- }
- }
- return null;
- }
-
- /**
- * Returns claims on success, or {@code null} if a redirect was already written to
- * {@code res}. Throws {@link HttpException} 401/403 for API clients.
- */
- private Map resolve(Request req, Response res) {
- return resolve(req, res, null);
- }
-
- private Map resolve(Request req, Response res, String resourceMetadataPath) {
- // 1. Bearer token
- String bearerToken = extractBearerToken(req.header("Authorization"));
- if (bearerToken != null) {
- try {
- return validator.validate(bearerToken);
- } catch (HttpException e) {
- res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath));
- throw e;
- }
- }
-
- // 2. Session cookie
- String sessionId = cookieValue(req, "oidc_session");
- if (sessionId != null) {
- Optional found = config.sessionStore().find(sessionId);
- if (found.isPresent()) {
- OidcSession session = found.get();
-
- if (!session.isAccessTokenExpired())
- return session.claims();
-
- // Access token expired — try silent refresh
- if (session.refreshToken() != null) {
- try {
- OidcSession refreshed = doRefresh(session);
- config.sessionStore().save(refreshed);
- return refreshed.claims();
- } catch (Exception ignored) {
- // Refresh failed — fall through to re-authenticate
- }
- }
- config.sessionStore().delete(sessionId);
- }
- }
-
- // 3. No valid credentials
- String accept = req.header("Accept");
- if (accept != null && accept.contains("application/json")) {
- res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath));
- throw HttpException.unauthorized();
- }
-
- // Browser — redirect to login, preserving the original URL in state
- String loginUrl = config.routePrefix() + "/login?redirect="
- + URLEncoder.encode(req.path(), StandardCharsets.UTF_8);
- res.redirect(loginUrl);
- return null;
- }
-
- private OidcSession doRefresh(OidcSession old) throws Exception {
- OidcTokenResponse tokens = tokenClient.refresh(
- meta.tokenEndpoint(), old.refreshToken());
-
- Map claims = mergeRefreshedClaims(tokens, old);
-
- return new OidcSession(
- old.id(),
- tokens.accessToken(),
- tokens.idToken() != null ? tokens.idToken() : old.idToken(),
- tokens.refreshToken() != null ? tokens.refreshToken() : old.refreshToken(),
- Instant.now().plusSeconds(tokens.expiresIn()),
- claims
- );
- }
-
- private void enforcePolicy(Map claims, OidcAuthPolicy policy, Response res) {
- checkRoles(claims, policy.requiredRoles());
- checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res);
- }
-
- private void checkRoles(Map claims, String[] required) {
- if (required.length == 0) return;
- if (rolesAllowed(claims, required)) return;
- throw HttpException.forbidden();
- }
-
- private void checkScopes(Map claims, String[] required, ScopesAllowed.Match match,
- Response res) {
- if (required.length == 0) return;
- if (scopesAllowed(claims, required, match)) return;
- res.header("WWW-Authenticate", insufficientScopeChallenge(required));
- throw HttpException.forbidden();
- }
-
- static String extractBearerToken(String authorizationHeader) {
- if (authorizationHeader == null) return null;
- int len = authorizationHeader.length();
- int start = 0;
- while (start < len && Character.isWhitespace(authorizationHeader.charAt(start))) start++;
- int schemeEnd = start + BEARER.length();
- if (schemeEnd > len || !authorizationHeader.regionMatches(true, start, BEARER, 0, BEARER.length())) {
- return null;
- }
- if (schemeEnd == len || !Character.isWhitespace(authorizationHeader.charAt(schemeEnd))) {
- return null;
- }
- int tokenStart = schemeEnd;
- while (tokenStart < len && Character.isWhitespace(authorizationHeader.charAt(tokenStart))) tokenStart++;
- if (tokenStart >= len) return null;
- int tokenEnd = len;
- while (tokenEnd > tokenStart && Character.isWhitespace(authorizationHeader.charAt(tokenEnd - 1))) tokenEnd--;
- return tokenEnd > tokenStart ? authorizationHeader.substring(tokenStart, tokenEnd) : null;
- }
-
- String bearerChallenge() {
- return bearerChallenge(null, null);
- }
-
- private String bearerChallenge(Request req, String resourceMetadataPath) {
- String base = BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
- if (resourceMetadataPath == null) return base;
- return base + ", resource_metadata=\"" + quoted(absoluteSelf(req, resourceMetadataPath)) + "\"";
- }
-
- String invalidTokenChallenge() {
- return invalidTokenChallenge(null, null);
- }
-
- private String invalidTokenChallenge(Request req, String resourceMetadataPath) {
- return bearerChallenge(req, resourceMetadataPath) + ", error=\"invalid_token\"";
- }
-
- String insufficientScopeChallenge(String[] requiredScopes) {
- return bearerChallenge() + ", error=\"insufficient_scope\", scope=\""
- + quoted(spaceDelimited(requiredScopes)) + "\"";
- }
-
- private String absoluteSelf(Request req, String path) {
- if (!path.startsWith("/")) return path;
- return selfOrigin(req, config.selfScheme()) + path;
- }
-
- /**
- * {@code scheme://host} clients actually reach this app on — the basis for every absolute
- * URL it publishes about itself (OAuth2 {@code redirect_uri}, the RFC 9728 resource
- * identifier and the {@code resource_metadata} challenge). Behind a reverse proxy the
- * request's own {@code Host} is the upstream address the proxy dialled, so
- * {@code X-Forwarded-Host}/{@code -Proto} win whenever present: without them the app would
- * name an address no client can resolve, and OAuth2 discovery fails with no error anyone
- * can trace back to here. Trusted unconditionally — a caller able to reach this app without
- * passing the proxy can do worse than spoof a self URL.
- */
- public static String selfOrigin(Request req, String fallbackScheme) {
- String forwardedHost = req.header("X-Forwarded-Host");
- if (forwardedHost == null) return fallbackScheme + "://" + req.header("Host");
- String forwardedProto = req.header("X-Forwarded-Proto");
- return (forwardedProto != null ? forwardedProto : fallbackScheme) + "://" + forwardedHost;
- }
-
- private static String spaceDelimited(String[] values) {
- if (values == null || values.length == 0) return "";
- StringBuilder sb = new StringBuilder();
- for (int i = 0; i < values.length; i++) {
- if (i > 0) sb.append(' ');
- sb.append(values[i]);
- }
- return sb.toString();
- }
-
- private static String quoted(String value) {
- StringBuilder out = new StringBuilder(value.length() + 8);
- for (int i = 0; i < value.length(); i++) {
- char c = value.charAt(i);
- if (c == '"' || c == '\\') out.append('\\');
- out.append(c);
- }
- return out.toString();
- }
-
- boolean rolesAllowed(Map claims, String[] required) {
- Object actual = valueAtPath(claims, roleClaimPathParts);
- if (actual == null) return false;
- for (String role : required) {
- if (containsToken(actual, role)) return true;
- }
- return false;
- }
-
- boolean scopesAllowed(Map claims, String[] required, ScopesAllowed.Match match) {
- if (match == ScopesAllowed.Match.ALL) {
- for (String scope : required) {
- if (!hasScope(claims, scope)) return false;
- }
- return true;
- }
- for (String scope : required) {
- if (hasScope(claims, scope)) return true;
- }
- return false;
- }
-
- private boolean hasScope(Map claims, String scope) {
- for (String[] pathParts : scopeClaimPathParts) {
- Object value = valueAtPath(claims, pathParts);
- if (value != null && containsToken(value, scope)) return true;
- }
- return false;
- }
-
- private static Object valueAtPath(Map claims, String[] pathParts) {
- Object current = claims;
- for (String part : pathParts) {
- if (!(current instanceof Map, ?> map)) return null;
- current = map.get(part);
- if (current == null) return null;
- }
- return current;
- }
-
- private static boolean containsToken(Object source, String token) {
- if (source instanceof String s) return containsDelimitedToken(s, token);
- if (source instanceof List> list) {
- for (Object item : list) {
- if (item == null) continue;
- if (tokenEquals(item.toString(), token)) return true;
- }
- return false;
- }
- if (source instanceof Object[] arr) {
- for (Object item : arr) {
- if (item == null) continue;
- if (tokenEquals(item.toString(), token)) return true;
- }
- return false;
- }
- return tokenEquals(source.toString(), token);
- }
-
- private static boolean containsDelimitedToken(String value, String token) {
- int len = value.length();
- int i = 0;
- while (i < len) {
- while (i < len && isScopeDelimiter(value.charAt(i))) i++;
- int start = i;
- while (i < len && !isScopeDelimiter(value.charAt(i))) i++;
- int end = i;
- if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) {
- return true;
- }
- }
- return false;
- }
-
- private static boolean tokenEquals(String value, String token) {
- int start = 0;
- int end = value.length();
- while (start < end && Character.isWhitespace(value.charAt(start))) start++;
- while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--;
- return end - start == token.length() && value.regionMatches(start, token, 0, token.length());
- }
-
- private static boolean isScopeDelimiter(char c) {
- return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
- }
-
- private static String[] splitClaimPath(String path) {
- if (path == null || path.isBlank()) {
- throw new IllegalStateException("OIDC claim path cannot be blank");
- }
- List parts = new ArrayList<>(4);
- int start = 0;
- int len = path.length();
- for (int i = 0; i <= len; i++) {
- if (i == len || path.charAt(i) == '.') {
- String p = path.substring(start, i).trim();
- if (!p.isEmpty()) parts.add(p);
- start = i + 1;
- }
- }
- if (parts.isEmpty()) {
- throw new IllegalStateException("OIDC claim path cannot be blank");
- }
- return parts.toArray(String[]::new);
- }
-
- private static String[][] splitClaimPaths(String paths) {
- String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths;
- List out = new ArrayList<>(4);
- int start = 0;
- int len = source.length();
- for (int i = 0; i <= len; i++) {
- if (i == len || source.charAt(i) == ',') {
- String raw = source.substring(start, i).trim();
- if (!raw.isEmpty()) out.add(splitClaimPath(raw));
- start = i + 1;
- }
- }
- if (out.isEmpty()) {
- return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") };
- }
- return out.toArray(String[][]::new);
- }
-
- private static Map mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) {
- Map merged = new HashMap<>();
- // Fall back to old claims first, then overlay fresh token claims
- merged.putAll(old.claims());
- if (tokens.accessToken() != null)
- merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
- if (tokens.idToken() != null)
- merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
- return Map.copyOf(merged);
- }
-
- // -- Shared cookie utility (also used by OidcExtension) -------------------
-
- static String cookieValue(Request req, String name) {
- String header = req.header("Cookie");
- if (header == null || header.isBlank()) return null;
- int len = header.length();
- int start = 0;
- while (start < len) {
- int semi = header.indexOf(';', start);
- int end = semi < 0 ? len : semi;
- int eq = header.indexOf('=', start);
- if (eq > start && eq < end) {
- int ns = start, ne = eq;
- while (ns < ne && header.charAt(ns) == ' ') ns++;
- while (ne > ns && header.charAt(ne-1) == ' ') ne--;
- if (ne - ns == name.length() && header.regionMatches(ns, name, 0, name.length()))
- return header.substring(eq + 1, end).strip();
- }
- start = end + 1;
- }
- return null;
- }
-}
diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java
new file mode 100644
index 0000000..7f7d6dd
--- /dev/null
+++ b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java
@@ -0,0 +1,43 @@
+package dev.relism.flash.ext.oidc;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * What stayed behind when authorization moved to {@code flash-ext-auth-core}: reading a bearer
+ * token off the wire, and the RFC 6750 challenges this source answers with. The matching of
+ * claims those credentials produce is {@code ClaimMatchingTest}'s job now.
+ */
+class OidcCredentialSourceTest {
+
+ private static OidcCredentialSource source() {
+ return new OidcCredentialSource(null, OidcConfig
+ .builder("https://idp.example.com", "client", "secret", "/auth/callback")
+ .build(), null, null);
+ }
+
+ @Test
+ void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() {
+ assertEquals("abc.def.ghi", OidcCredentialSource.extractBearerToken("Bearer abc.def.ghi"));
+ assertEquals("abc", OidcCredentialSource.extractBearerToken(" bearer abc "));
+ assertNull(OidcCredentialSource.extractBearerToken("Basic Zm9vOmJhcg=="));
+ assertNull(OidcCredentialSource.extractBearerToken("Bearer"));
+ }
+
+ @Test
+ void bearerChallenge_containsRealmAndRfcErrors() {
+ OidcCredentialSource src = source();
+
+ String basic = src.bearerChallenge();
+ String invalid = src.invalidTokenChallenge();
+ String insufficient = src.insufficientScopeChallenge(new String[]{"orders:read", "payments:write"});
+
+ assertTrue(basic.startsWith("Bearer realm=\""));
+ assertTrue(invalid.contains("error=\"invalid_token\""));
+ assertTrue(insufficient.contains("error=\"insufficient_scope\""));
+ assertTrue(insufficient.contains("scope=\"orders:read payments:write\""));
+ }
+}
diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java
deleted file mode 100644
index b38a1ae..0000000
--- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java
+++ /dev/null
@@ -1,72 +0,0 @@
-package dev.relism.flash.ext.oidc;
-
-import org.junit.jupiter.api.Test;
-
-import java.util.List;
-import java.util.Map;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class OidcMiddlewareAuthzTest {
-
- private static OidcMiddleware middleware(String rolesPath, String scopePaths) {
- OidcConfig cfg = OidcConfig.builder("https://idp.example.com", "client", "secret", "/auth/callback")
- .rolesClaimPath(rolesPath)
- .scopeClaimPaths(scopePaths)
- .build();
- return new OidcMiddleware(null, cfg, null, null);
- }
-
- @Test
- void rolesAllowed_readsConfiguredNestedClaimPath() {
- OidcMiddleware mw = middleware("realm_access.roles", "scope,scp");
- Map claims = Map.of("realm_access", Map.of("roles", List.of("user", "admin")));
-
- assertTrue(mw.rolesAllowed(claims, new String[]{"admin"}));
- assertFalse(mw.rolesAllowed(claims, new String[]{"ops"}));
- }
-
- @Test
- void scopesAllowed_all_requiresEveryScope() {
- OidcMiddleware mw = middleware("roles", "scope,scp");
- Map claims = Map.of("scope", "openid profile orders:read");
-
- assertTrue(mw.scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL));
- assertFalse(mw.scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL));
- }
-
- @Test
- void scopesAllowed_any_acceptsAnyConfiguredScopeSource() {
- OidcMiddleware mw = middleware("roles", "scope,scp,permissions.scopes");
- Map claims = Map.of(
- "scp", List.of("payments:write"),
- "permissions", Map.of("scopes", "orders:approve")
- );
-
- assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve", "orders:read"}, ScopesAllowed.Match.ANY));
- assertTrue(mw.scopesAllowed(claims, new String[]{"payments:write"}, ScopesAllowed.Match.ANY));
- assertFalse(mw.scopesAllowed(claims, new String[]{"unknown"}, ScopesAllowed.Match.ANY));
- }
-
- @Test
- void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() {
- assertEquals("abc.def.ghi", OidcMiddleware.extractBearerToken("Bearer abc.def.ghi"));
- assertEquals("abc", OidcMiddleware.extractBearerToken(" bearer abc "));
- assertNull(OidcMiddleware.extractBearerToken("Basic Zm9vOmJhcg=="));
- assertNull(OidcMiddleware.extractBearerToken("Bearer"));
- }
-
- @Test
- void bearerChallenge_containsRealmAndRfcErrors() {
- OidcMiddleware mw = middleware("roles", "scope,scp");
-
- String basic = mw.bearerChallenge();
- String invalid = mw.invalidTokenChallenge();
- String insufficient = mw.insufficientScopeChallenge(new String[]{"orders:read", "payments:write"});
-
- assertTrue(basic.startsWith("Bearer realm=\""));
- assertTrue(invalid.contains("error=\"invalid_token\""));
- assertTrue(insufficient.contains("error=\"insufficient_scope\""));
- assertTrue(insufficient.contains("scope=\"orders:read payments:write\""));
- }
-}
diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java
index 41b8bdf..2597e90 100644
--- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java
+++ b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java
@@ -4,6 +4,10 @@ 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.ext.openapi.OpenApiContributor;
+import dev.relism.flash.ext.auth.AuthPolicy;
+import dev.relism.flash.ext.auth.Authenticated;
+import dev.relism.flash.ext.auth.RolesAllowed;
+import dev.relism.flash.ext.auth.ScopesAllowed;
import dev.relism.flash.extension.FlashContext;
import org.junit.jupiter.api.Test;
diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml
index aaa38c3..a4f195e 100644
--- a/flash-extensions/pom.xml
+++ b/flash-extensions/pom.xml
@@ -16,6 +16,7 @@
flash-ext-jackson
flash-ext-openapi
+ flash-ext-auth-core
flash-ext-oidc
flash-ext-routeviewer
flash-ext-view-core
@@ -45,6 +46,11 @@
flash-ext-scheduler
${project.version}
+
+ dev.relism
+ flash-ext-auth-core
+ ${project.version}
+
dev.relism
flash-ext-cache-core