From c5be6ac7b8cc65dcb403375c87bdf7f619195bb7 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Thu, 10 Sep 2026 19:00:39 +0000 Subject: [PATCH] refactor(ext-auth): extract flash-ext-auth-core out of flash-ext-oidc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -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(). --- flash-extensions/flash-ext-auth-core/pom.xml | 25 + .../dev/relism/flash/ext/auth/AuthConfig.java | 43 ++ .../relism/flash/ext/auth/AuthMiddleware.java | 280 +++++++++ .../relism/flash/ext/auth/AuthPolicy.java} | 36 +- .../relism/flash/ext/auth}/Authenticated.java | 18 +- .../dev/relism/flash/ext/auth/Claims.java} | 37 +- .../relism/flash/ext/auth/ClaimsHolder.java | 64 ++ .../flash/ext/auth/CredentialSource.java | 48 ++ .../relism/flash/ext/auth}/RolesAllowed.java | 15 +- .../relism/flash/ext/auth}/ScopesAllowed.java | 6 +- .../flash/ext/auth/AuthPolicyTest.java} | 22 +- .../flash/ext/auth}/ClaimMatchingTest.java | 18 +- .../flash/ext/auth/ClaimsScopesTest.java} | 12 +- .../flash/ext/mcp/McpOidcIntegration.java | 46 +- .../flash/ext/mcp/FakeOidcProvider.java | 2 +- .../authenticatedonly/PointlessAuthTool.java | 2 +- .../authfixtures/secured/AdminOnlyTool.java | 2 +- .../authfixtures/secured/WriteScopeTool.java | 2 +- flash-extensions/flash-ext-oidc/pom.xml | 4 + .../relism/flash/ext/oidc/ClaimsHolder.java | 71 --- .../flash/ext/oidc/OidcCredentialSource.java | 316 ++++++++++ .../relism/flash/ext/oidc/OidcExtension.java | 37 +- .../relism/flash/ext/oidc/OidcMiddleware.java | 550 ------------------ .../ext/oidc/OidcCredentialSourceTest.java | 43 ++ .../ext/oidc/OidcMiddlewareAuthzTest.java | 72 --- .../ext/oidc/OidcOpenApiInteropTest.java | 4 + flash-extensions/pom.xml | 6 + 27 files changed, 966 insertions(+), 815 deletions(-) create mode 100644 flash-extensions/flash-ext-auth-core/pom.xml create mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java create mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java rename flash-extensions/{flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java => flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java} (71%) rename flash-extensions/{flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc => flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth}/Authenticated.java (67%) rename flash-extensions/{flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java => flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java} (85%) create mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java create mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/CredentialSource.java rename flash-extensions/{flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc => flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth}/RolesAllowed.java (56%) rename flash-extensions/{flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc => flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth}/ScopesAllowed.java (87%) rename flash-extensions/{flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java => flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/AuthPolicyTest.java} (74%) rename flash-extensions/{flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc => flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth}/ClaimMatchingTest.java (94%) rename flash-extensions/{flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java => flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimsScopesTest.java} (74%) delete mode 100644 flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java create mode 100644 flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java delete mode 100644 flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java create mode 100644 flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java delete mode 100644 flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java diff --git a/flash-extensions/flash-ext-auth-core/pom.xml b/flash-extensions/flash-ext-auth-core/pom.xml new file mode 100644 index 0000000..abc299f --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-auth-core + + + + dev.relism + flash + + + org.junit.jupiter + junit-jupiter + + + diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java new file mode 100644 index 0000000..6df58af --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java @@ -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. + * + *

Defaults are the generic spelling, not any one provider's. A source that knows better — + * {@code flash-ext-auth-oidc} defaults roles to Keycloak's {@code realm_access.roles} — builds + * its own {@code AuthConfig} with the paths its provider actually uses. + */ +public final class AuthConfig { + + private final String rolesClaimPath; + private final String scopeClaimPaths; + + private AuthConfig(Builder b) { + this.rolesClaimPath = b.rolesClaimPath; + this.scopeClaimPaths = b.scopeClaimPaths; + } + + /** Dot-separated path to the roles list in the claims (default: {@code roles}). */ + public String rolesClaimPath() { return rolesClaimPath; } + + /** Comma-separated claim paths scopes are read from, in order (default: {@code scope,scp}). */ + public String scopeClaimPaths() { return scopeClaimPaths; } + + public static Builder builder() { return new Builder(); } + + public static final class Builder { + private String rolesClaimPath = "roles"; + private String scopeClaimPaths = "scope,scp"; + + private Builder() {} + + /** Dot-separated path to the roles list — e.g. {@code realm_access.roles}, {@code groups}. */ + public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; } + /** Comma-separated claim paths scopes are read from, tried in order. */ + public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; } + + public AuthConfig build() { return new AuthConfig(this); } + } +} diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java new file mode 100644 index 0000000..30c7a4d --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java @@ -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}. + * + *

It knows nothing about how the caller proved who they are — that is the + * {@link CredentialSource} it is built with. What lives here is the half that is the same for + * every mechanism: publish the claims for the request, match roles and scopes against them, clear + * up afterwards. + * + *

{@code
+ * AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
+ * app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect());
+ * app.delete("/admin/users/{id}", handler, auth.requireRole("admin"));
+ * }
+ */ +public class AuthMiddleware { + + 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 claims = source.authenticate(req, res); + if (claims == null) return null; // the source already answered the request + ClaimsHolder.set(claims); + try { + return next.handle(req, res); + } finally { + ClaimsHolder.clear(); + } + }; + } + + /** + * Publishes claims when the caller happens to be authenticated and never rejects anyone. Use + * it on public routes that personalise their response for signed-in callers. + * + *
{@code
+     * app.get("/", handler, auth.optional());
+     * // Inside handler: ClaimsHolder.current() is non-null iff the caller is signed in.
+     * }
+ */ + public Middleware optional() { + return next -> (req, res) -> { + Map claims = source.peek(req); + if (claims != null) ClaimsHolder.set(claims); + try { + return next.handle(req, res); + } finally { + ClaimsHolder.clear(); + } + }; + } + + /** + * Applies a policy compiled once at boot from a handler's annotations. This is the path + * annotation-driven mounting takes. + */ + public Middleware authorize(AuthPolicy policy) { + if (policy.optionalAuth()) return optional(); + return next -> (req, res) -> { + Map claims = source.authenticate(req, res); + if (claims == null) return null; + enforcePolicy(claims, policy, res); + ClaimsHolder.set(claims); + try { + return next.handle(req, res); + } finally { + ClaimsHolder.clear(); + } + }; + } + + /** {@link #protect()} plus at least one of the given roles (OR semantics). */ + public Middleware requireRole(String... roles) { + return authorize(AuthPolicy.rolesAny(roles)); + } + + /** {@link #protect()} plus every one of the given scopes. */ + public Middleware requireScopes(String... scopes) { + return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL)); + } + + /** {@link #protect()} plus at least one of the given scopes. */ + public Middleware requireAnyScope(String... scopes) { + return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY)); + } + + // -- Policy enforcement --------------------------------------------------- + + private void enforcePolicy(Map claims, AuthPolicy policy, Response res) { + checkRoles(claims, policy.requiredRoles()); + checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res); + } + + private void checkRoles(Map claims, String[] required) { + if (required.length == 0) return; + if (rolesAllowed(claims, required)) return; + throw HttpException.forbidden(); + } + + private void checkScopes(Map claims, String[] required, ScopesAllowed.Match match, + Response res) { + if (required.length == 0) return; + if (scopesAllowed(claims, required, match)) return; + String challenge = source.insufficientScopeChallenge(required); + if (challenge != null) res.header("WWW-Authenticate", challenge); + throw HttpException.forbidden(); + } + + // -- Claim matching ------------------------------------------------------- + + boolean rolesAllowed(Map claims, String[] required) { + Object actual = valueAtPath(claims, roleClaimPathParts); + if (actual == null) return false; + for (String role : required) { + if (containsToken(actual, role)) return true; + } + return false; + } + + boolean scopesAllowed(Map claims, String[] required, ScopesAllowed.Match match) { + if (match == ScopesAllowed.Match.ALL) { + for (String scope : required) { + if (!hasScope(claims, scope)) return false; + } + return true; + } + for (String scope : required) { + if (hasScope(claims, scope)) return true; + } + return false; + } + + private boolean hasScope(Map claims, String scope) { + for (String[] pathParts : scopeClaimPathParts) { + Object value = valueAtPath(claims, pathParts); + if (value != null && containsToken(value, scope)) return true; + } + return false; + } + + private static Object valueAtPath(Map claims, String[] pathParts) { + Object current = claims; + for (String part : pathParts) { + if (!(current instanceof Map map)) return null; + current = map.get(part); + if (current == null) return null; + } + return current; + } + + private static boolean containsToken(Object source, String token) { + if (source instanceof String s) return containsDelimitedToken(s, token); + if (source instanceof List list) { + for (Object item : list) { + if (item == null) continue; + if (tokenEquals(item.toString(), token)) return true; + } + return false; + } + if (source instanceof Object[] arr) { + for (Object item : arr) { + if (item == null) continue; + if (tokenEquals(item.toString(), token)) return true; + } + return false; + } + return tokenEquals(source.toString(), token); + } + + private static boolean containsDelimitedToken(String value, String token) { + int len = value.length(); + int i = 0; + while (i < len) { + while (i < len && isScopeDelimiter(value.charAt(i))) i++; + int start = i; + while (i < len && !isScopeDelimiter(value.charAt(i))) i++; + int end = i; + if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) { + return true; + } + } + return false; + } + + private static boolean tokenEquals(String value, String token) { + int start = 0; + int end = value.length(); + while (start < end && Character.isWhitespace(value.charAt(start))) start++; + while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--; + return end - start == token.length() && value.regionMatches(start, token, 0, token.length()); + } + + private static boolean isScopeDelimiter(char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ','; + } + + private static String[] splitClaimPath(String path) { + if (path == null || path.isBlank()) { + throw new IllegalStateException("Claim path cannot be blank"); + } + List parts = new ArrayList<>(4); + int start = 0; + int len = path.length(); + for (int i = 0; i <= len; i++) { + if (i == len || path.charAt(i) == '.') { + String p = path.substring(start, i).trim(); + if (!p.isEmpty()) parts.add(p); + start = i + 1; + } + } + if (parts.isEmpty()) { + throw new IllegalStateException("Claim path cannot be blank"); + } + return parts.toArray(String[]::new); + } + + private static String[][] splitClaimPaths(String paths) { + String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths; + List out = new ArrayList<>(4); + int start = 0; + int len = source.length(); + for (int i = 0; i <= len; i++) { + if (i == len || source.charAt(i) == ',') { + String raw = source.substring(start, i).trim(); + if (!raw.isEmpty()) out.add(splitClaimPath(raw)); + start = i + 1; + } + } + if (out.isEmpty()) { + return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") }; + } + return out.toArray(String[][]::new); + } +} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java similarity index 71% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java rename to flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java index 4f7c5b1..8cdb741 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java @@ -1,4 +1,4 @@ -package dev.relism.flash.ext.oidc; +package dev.relism.flash.ext.auth; import java.util.LinkedHashSet; import java.util.List; @@ -7,13 +7,13 @@ import java.util.List; * Compiled authorization policy derived from handler annotations at mount time. * Immutable and allocation-free on the request hot path. */ -final class OidcAuthPolicy { +public final class AuthPolicy { private static final String[] EMPTY = new String[0]; - private static final OidcAuthPolicy AUTH_REQUIRED = new OidcAuthPolicy( + private static final AuthPolicy AUTH_REQUIRED = new AuthPolicy( false, EMPTY, EMPTY, ScopesAllowed.Match.ALL); - private static final OidcAuthPolicy AUTH_OPTIONAL = new OidcAuthPolicy( + private static final AuthPolicy AUTH_OPTIONAL = new AuthPolicy( true, EMPTY, EMPTY, ScopesAllowed.Match.ALL); private final boolean optionalAuth; @@ -21,7 +21,7 @@ final class OidcAuthPolicy { private final String[] requiredScopes; private final ScopesAllowed.Match scopeMatch; - private OidcAuthPolicy(boolean optionalAuth, + private AuthPolicy(boolean optionalAuth, String[] requiredRoles, String[] requiredScopes, ScopesAllowed.Match scopeMatch) { @@ -31,19 +31,19 @@ final class OidcAuthPolicy { this.scopeMatch = scopeMatch; } - static OidcAuthPolicy authenticated() { return AUTH_REQUIRED; } + public static AuthPolicy authenticated() { return AUTH_REQUIRED; } - static OidcAuthPolicy optional() { return AUTH_OPTIONAL; } + public static AuthPolicy optional() { return AUTH_OPTIONAL; } - static OidcAuthPolicy rolesAny(String... roles) { - return new OidcAuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL); + public static AuthPolicy rolesAny(String... roles) { + return new AuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL); } - static OidcAuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) { - return new OidcAuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match); + public static AuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) { + return new AuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match); } - static OidcAuthPolicy compileFromAnnotations(Class handlerClass) { + public static AuthPolicy compileFromAnnotations(Class handlerClass) { Authenticated auth = handlerClass.getAnnotation(Authenticated.class); RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class); ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class); @@ -60,10 +60,10 @@ final class OidcAuthPolicy { + handlerClass.getName()); } - return new OidcAuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch); + return new AuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch); } - static List openApiScopesFor(Class handlerClass) { + public static List 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> HOLDER = new ThreadLocal<>(); + + private ClaimsHolder() {} + + /** Called by {@link AuthMiddleware} once a source has authenticated the request. */ + static void set(Map 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 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 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 claims = HOLDER.get(); + if (claims == null) return null; + Object v = claims.get(key); + return v != null ? v.toString() : null; + } +} diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/CredentialSource.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/CredentialSource.java new file mode 100644 index 0000000..4ceb2f5 --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/CredentialSource.java @@ -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. + * + *

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. + * + *

Three outcomes, and the difference between the last two matters: + *

    + *
  • claims — the caller presented a valid credential;
  • + *
  • {@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;
  • + *
  • {@link HttpException} — a credential was presented and is invalid. The source + * sets any challenge header it owes the caller before throwing.
  • + *
+ */ + Map 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 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; } +} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/RolesAllowed.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/RolesAllowed.java similarity index 56% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/RolesAllowed.java rename to flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/RolesAllowed.java index 1009f14..b37020d 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/RolesAllowed.java +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/RolesAllowed.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,20 +6,19 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Restricts a handler to callers whose JWT contains at least one of the - * specified roles. Authentication is implicitly required — no need to combine - * with {@link Authenticated}. + * 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}. * - *

Roles are read from the claim configured in {@link OidcConfig#rolesClaimPath()} - * (default: {@code "roles"}). Nested paths like {@code "realm_access.roles"} are - * supported with dot notation. + *

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. * *

{@code
  * @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
  * @RolesAllowed("admin")
  * public class DeleteBlog extends JacksonHandler { ... }
  *
- * // Multiple accepted roles (OR semantics — any one role is sufficient):
+ * // Multiple accepted roles (OR semantics — any one is sufficient):
  * @RolesAllowed({"admin", "editor"})
  * public class UpdateBlog extends JacksonHandler { ... }
  * }
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ScopesAllowed.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ScopesAllowed.java similarity index 87% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ScopesAllowed.java rename to flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ScopesAllowed.java index a5ba66f..40cd8c5 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ScopesAllowed.java +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ScopesAllowed.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,11 +6,11 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Restricts a handler to callers whose token carries the required OAuth2 scopes. + * Restricts a handler to callers whose credential carries the required scopes. * Authentication is implicitly required. * *

Scopes are resolved from the configured claim paths in - * {@link OidcConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support + * {@link AuthConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support * both standard formats: *

    *
  • {@code scope}: space-separated string
  • diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/AuthPolicyTest.java similarity index 74% rename from flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java rename to flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/AuthPolicyTest.java index e1677a5..a2e8139 100644 --- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java +++ b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/AuthPolicyTest.java @@ -1,4 +1,4 @@ -package dev.relism.flash.ext.oidc; +package dev.relism.flash.ext.auth; import org.junit.jupiter.api.Test; @@ -6,7 +6,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.*; -class OidcAuthPolicyTest { +class AuthPolicyTest { static class PlainHandler {} @@ -33,12 +33,12 @@ class OidcAuthPolicyTest { @Test void compileFromAnnotations_noSecurityAnnotations_returnsNull() { - assertNull(OidcAuthPolicy.compileFromAnnotations(PlainHandler.class)); + assertNull(AuthPolicy.compileFromAnnotations(PlainHandler.class)); } @Test void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() { - OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(AuthenticatedHandler.class); + AuthPolicy policy = AuthPolicy.compileFromAnnotations(AuthenticatedHandler.class); assertNotNull(policy); assertFalse(policy.optionalAuth()); assertEquals(0, policy.requiredRoles().length); @@ -47,14 +47,14 @@ class OidcAuthPolicyTest { @Test void compileFromAnnotations_optionalAuth_createsOptionalPolicy() { - OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(OptionalHandler.class); + AuthPolicy policy = AuthPolicy.compileFromAnnotations(OptionalHandler.class); assertNotNull(policy); assertTrue(policy.optionalAuth()); } @Test void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() { - OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(CombinedHandler.class); + AuthPolicy policy = AuthPolicy.compileFromAnnotations(CombinedHandler.class); assertNotNull(policy); assertFalse(policy.optionalAuth()); assertArrayEquals(new String[]{"admin"}, policy.requiredRoles()); @@ -64,7 +64,7 @@ class OidcAuthPolicyTest { @Test void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() { - OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(ScopesHandler.class); + AuthPolicy policy = AuthPolicy.compileFromAnnotations(ScopesHandler.class); assertNotNull(policy); assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes()); assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch()); @@ -73,22 +73,22 @@ class OidcAuthPolicyTest { @Test void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() { assertThrows(IllegalStateException.class, - () -> OidcAuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class)); + () -> AuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class)); } @Test void openApiScopesFor_returnsScopesWhenPresent() { assertEquals(List.of("orders:write", "payments:write"), - OidcAuthPolicy.openApiScopesFor(ScopesHandler.class)); + AuthPolicy.openApiScopesFor(ScopesHandler.class)); } @Test void openApiScopesFor_rolesOnly_returnsEmptyList() { - assertEquals(List.of(), OidcAuthPolicy.openApiScopesFor(RolesHandler.class)); + assertEquals(List.of(), AuthPolicy.openApiScopesFor(RolesHandler.class)); } @Test void openApiScopesFor_noSecurity_returnsNull() { - assertNull(OidcAuthPolicy.openApiScopesFor(PlainHandler.class)); + assertNull(AuthPolicy.openApiScopesFor(PlainHandler.class)); } } diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/ClaimMatchingTest.java b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimMatchingTest.java similarity index 94% rename from flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/ClaimMatchingTest.java rename to flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimMatchingTest.java index 82132b2..f74f63f 100644 --- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/ClaimMatchingTest.java +++ b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimMatchingTest.java @@ -1,4 +1,4 @@ -package dev.relism.flash.ext.oidc; +package dev.relism.flash.ext.auth; import org.junit.jupiter.api.Test; @@ -23,15 +23,15 @@ import static org.junit.jupiter.api.Assertions.assertTrue; */ class ClaimMatchingTest { - private static OidcMiddleware middleware(String rolesPath, String scopePaths) { - return new OidcMiddleware(null, OidcConfig - .builder("https://idp.example.com", "client", "secret", "/auth/callback") + /** 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, null); + .build(), null); } - private static OidcMiddleware middleware() { + private static AuthMiddleware middleware() { return middleware("realm_access.roles", "scope,scp"); } @@ -182,7 +182,7 @@ class ClaimMatchingTest { @Test void scopesAreLookedForInEveryConfiguredPathUntilOneMatches() { - OidcMiddleware mw = middleware("roles", "scope, scp , permissions.scopes"); + AuthMiddleware mw = middleware("roles", "scope, scp , permissions.scopes"); Map claims = Map.of( "scp", List.of("payments:write"), "permissions", Map.of("scopes", "orders:approve")); @@ -196,14 +196,14 @@ class ClaimMatchingTest { @Test void blankScopePathsFallBackToScopeAndScp() { - OidcMiddleware mw = middleware("roles", " "); + 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() { - OidcMiddleware mw = middleware("roles", " , , "); + AuthMiddleware mw = middleware("roles", " , , "); assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL)); } } diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimsScopesTest.java similarity index 74% rename from flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java rename to flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimsScopesTest.java index 4fbcc67..6e6fe1e 100644 --- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java +++ b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimsScopesTest.java @@ -1,4 +1,4 @@ -package dev.relism.flash.ext.oidc; +package dev.relism.flash.ext.auth; import org.junit.jupiter.api.Test; @@ -7,11 +7,11 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.*; -class OidcUserScopesTest { +class ClaimsScopesTest { @Test void scopes_readsStandardScopeString() { - OidcUser user = new OidcUser(Map.of("scope", "openid profile orders:read")); + 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")); @@ -20,7 +20,7 @@ class OidcUserScopesTest { @Test void scopes_fallsBackToScpArray() { - OidcUser user = new OidcUser(Map.of("scp", List.of("orders:write", "payments:write"))); + 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")); @@ -28,7 +28,7 @@ class OidcUserScopesTest { @Test void scopes_supportsCustomClaimPaths() { - OidcUser user = new OidcUser(Map.of("permissions", Map.of("scopes", List.of("a", "b")))); + 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")); @@ -37,7 +37,7 @@ class OidcUserScopesTest { @Test void scopes_combinesMultipleClaimPathsInOrder() { - OidcUser user = new OidcUser(Map.of( + Claims user = new Claims(Map.of( "scope", "openid", "scp", List.of("profile", "orders:read") )); diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java index 80eae76..c971968 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java @@ -1,11 +1,12 @@ package dev.relism.flash.ext.mcp; -import dev.relism.flash.ext.oidc.Authenticated; -import dev.relism.flash.ext.oidc.ClaimsHolder; -import dev.relism.flash.ext.oidc.OidcMiddleware; -import dev.relism.flash.ext.oidc.OidcUser; -import dev.relism.flash.ext.oidc.RolesAllowed; -import dev.relism.flash.ext.oidc.ScopesAllowed; +import dev.relism.flash.ext.auth.AuthMiddleware; +import dev.relism.flash.ext.auth.Authenticated; +import dev.relism.flash.ext.auth.Claims; +import dev.relism.flash.ext.auth.ClaimsHolder; +import dev.relism.flash.ext.auth.RolesAllowed; +import dev.relism.flash.ext.auth.ScopesAllowed; +import dev.relism.flash.ext.oidc.OidcCredentialSource; import dev.relism.flash.exceptions.HttpException; import dev.relism.flash.extension.FlashContext; import dev.relism.flash.models.Request; @@ -19,7 +20,7 @@ import java.util.function.Function; import java.util.function.Supplier; /** - * Lazy, isolated bridge to {@code flash-ext-oidc}. + * Lazy, isolated bridge to {@code flash-ext-oidc} and {@code flash-ext-auth-core}. * *

    References to OIDC types only ever resolve when {@link #resolve}/{@link #compileToolPolicy} * are actually invoked — never at {@link McpExtension} class-load time — because they live in @@ -34,7 +35,7 @@ import java.util.function.Supplier; *

    Zero-config by design: when {@code flash-ext-oidc} is installed, everything an MCP OAuth2 * resource server needs — issuer, canonical resource identifier, RFC 8707 audience binding, and * a spec-compliant {@code WWW-Authenticate} challenge (RFC 9728 §5.1) — is derived straight from - * the installed {@link OidcMiddleware}, with no additional {@link McpConfig} calls. + * the installed {@link OidcCredentialSource}, with no additional {@link McpConfig} calls. * {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)} * remain as explicit overrides for the rare case where that guess is wrong. */ @@ -51,20 +52,25 @@ final class McpOidcIntegration { /** Returns the resolved security bundle, or {@code null} if oidc is not installed. */ static Resolved resolve(FlashContext ctx, McpConfig config) { - Optional oidc = ctx.find(OidcMiddleware.class); - if (oidc.isEmpty()) return null; + // Deliberately keyed on the OIDC source and not on AuthMiddleware: McpSecurity means + // "a real OAuth2 authorization server is protecting this endpoint", and an app that + // authenticates some other way must not satisfy REQUIRED by accident. + Optional oidc = ctx.find(OidcCredentialSource.class); + Optional auth = ctx.find(AuthMiddleware.class); + if (oidc.isEmpty() || auth.isEmpty()) return null; - OidcMiddleware oidcMw = oidc.get(); + OidcCredentialSource source = oidc.get(); + AuthMiddleware authMw = auth.get(); String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath(); String issuer = config.authorizationServerIssuer() != null - ? config.authorizationServerIssuer() : oidcMw.issuer(); + ? config.authorizationServerIssuer() : source.issuer(); Function resourceId = req -> config.resourceIdentifier() != null ? config.resourceIdentifier() - : OidcMiddleware.selfOrigin(req, oidcMw.selfScheme()) + config.rootPath(); + : OidcCredentialSource.selfOrigin(req, source.selfScheme()) + config.rootPath(); - Middleware protect = oidcMw.protect(resourceMetadataPath); + Middleware protect = authMw.withSource(source.withResourceMetadata(resourceMetadataPath)).protect(); Middleware secured = Middleware.of(protect, audienceGuard(resourceId)); - return new Resolved(secured, issuer, oidcMw.rolesClaimPath(), resourceId); + return new Resolved(secured, issuer, authMw.rolesClaimPath(), resourceId); } /** @@ -73,7 +79,7 @@ final class McpOidcIntegration { */ private static Middleware audienceGuard(Function resourceIdentifier) { return next -> (req, res) -> { - Map claims = ClaimsHolder.get(); + Map claims = ClaimsHolder.map(); String expected = resourceIdentifier.apply(req); if (claims != null && !audienceMatches(claims.get("aud"), expected)) { log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " + @@ -100,7 +106,7 @@ final class McpOidcIntegration { * annotations. Called once per tool at boot ({@link McpRegistry#scan}), never on the * request hot path — the {@link Supplier} it returns is what runs per {@code tools/call}, * closing over the already-normalized role/scope arrays so the hot path itself allocates - * nothing beyond what {@link OidcUser#hasRole}/{@link OidcUser#hasScope} already do. + * nothing beyond what {@link Claims#hasRole}/{@link Claims#hasScope} already do. * *

    Fails fast at boot, not silently at request time, for the two ways this can be * misconfigured: the annotation present without OAuth2 actually protecting this MCP server @@ -136,7 +142,7 @@ final class McpOidcIntegration { ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL; Supplier check = () -> { - OidcUser user = ClaimsHolder.user(); + Claims user = ClaimsHolder.current(); if (user == null) return "not authenticated"; if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles)) return "missing required role (any of: " + String.join(", ", requiredRoles) + ")"; @@ -147,12 +153,12 @@ final class McpOidcIntegration { return new McpAuthPolicy(check); } - private static boolean hasAnyRole(OidcUser user, String claimPath, String[] roles) { + private static boolean hasAnyRole(Claims user, String claimPath, String[] roles) { for (String role : roles) if (user.hasRole(claimPath, role)) return true; return false; } - private static boolean hasScopes(OidcUser user, String[] scopes, ScopesAllowed.Match match) { + private static boolean hasScopes(Claims user, String[] scopes, ScopesAllowed.Match match) { if (match == ScopesAllowed.Match.ALL) { for (String scope : scopes) if (!user.hasScope(scope)) return false; return true; diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java index 8f0bc37..8eb5f29 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java @@ -63,7 +63,7 @@ final class FakeOidcProvider implements AutoCloseable { /** * Same as {@link #signToken(String, String)}, plus a {@code scope} claim (space-delimited, - * matching {@link dev.relism.flash.ext.oidc.OidcUser#hasScope}'s default claim path) and a + * matching {@link dev.relism.flash.ext.auth.Claims#hasScope}'s default claim path) and a * Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default * {@code rolesClaimPath}) when {@code roles} is non-empty. */ diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java index 9d9a622..71934d9 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java @@ -5,7 +5,7 @@ import dev.relism.flash.ext.mcp.TextContent; import dev.relism.flash.ext.mcp.Tool; import dev.relism.flash.ext.mcp.ToolArguments; import dev.relism.flash.ext.mcp.ToolResponse; -import dev.relism.flash.ext.oidc.Authenticated; +import dev.relism.flash.ext.auth.Authenticated; /** Deliberately misconfigured fixture: bare @Authenticated has no effect on an McpTool — see * McpOidcIntegration#compileToolPolicy. Boot must fail with a clear message, not silently no-op. */ diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java index 1bd96f5..9da014e 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java @@ -5,7 +5,7 @@ import dev.relism.flash.ext.mcp.TextContent; import dev.relism.flash.ext.mcp.Tool; import dev.relism.flash.ext.mcp.ToolArguments; import dev.relism.flash.ext.mcp.ToolResponse; -import dev.relism.flash.ext.oidc.RolesAllowed; +import dev.relism.flash.ext.auth.RolesAllowed; @Tool(name = "admin_only", description = "Only callable with the admin role") @RolesAllowed("admin") diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java index 14b390f..aa4a3c0 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java @@ -5,7 +5,7 @@ import dev.relism.flash.ext.mcp.TextContent; import dev.relism.flash.ext.mcp.Tool; import dev.relism.flash.ext.mcp.ToolArguments; import dev.relism.flash.ext.mcp.ToolResponse; -import dev.relism.flash.ext.oidc.ScopesAllowed; +import dev.relism.flash.ext.auth.ScopesAllowed; @Tool(name = "write_only", description = "Only callable with the write scope") @ScopesAllowed("write") diff --git a/flash-extensions/flash-ext-oidc/pom.xml b/flash-extensions/flash-ext-oidc/pom.xml index 374d708..9967473 100644 --- a/flash-extensions/flash-ext-oidc/pom.xml +++ b/flash-extensions/flash-ext-oidc/pom.xml @@ -13,6 +13,10 @@ flash-ext-oidc + + dev.relism + flash-ext-auth-core + dev.relism flash diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java deleted file mode 100644 index 490147a..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java +++ /dev/null @@ -1,71 +0,0 @@ -package dev.relism.flash.ext.oidc; - -import java.util.Map; - -/** - * Thread-local store for JWT claims, populated by the OIDC middleware before - * the handler runs and cleared in the {@code finally} block afterward. - * - *

    Safe with virtual threads: each request gets its own virtual thread, so - * {@link ThreadLocal} values are naturally isolated per request. - * - *

    {@code
    - * // Inside any handler protected by @Authenticated or @RolesAllowed:
    - *
    - * // Preferred — typed wrapper:
    - * OidcUser user = ClaimsHolder.user();
    - * String email  = user.email();
    - * List roles = user.roles("realm_access.roles");
    - *
    - * // Raw escape hatch:
    - * Map all = ClaimsHolder.get();
    - * }
    - */ -public final class ClaimsHolder { - - private static final ThreadLocal> HOLDER = new ThreadLocal<>(); - - private ClaimsHolder() {} - - /** Called by the OIDC middleware after successful token validation. */ - static void set(Map claims) { - HOLDER.set(claims); - } - - /** Called by the OIDC middleware in the {@code finally} block. */ - static void clear() { - HOLDER.remove(); - } - - /** - * Returns a type-safe {@link OidcUser} view of the current request's claims, - * or {@code null} if the route is not protected by OIDC middleware. - * - *

    This is the preferred entry point for both lambda and class-based handlers. - */ - public static OidcUser user() { - Map claims = HOLDER.get(); - return claims != null ? new OidcUser(claims) : null; - } - - /** - * Returns the raw claims map for the current request, or {@code null} if - * the route is not protected by OIDC middleware. - * - * @see #user() for the preferred type-safe accessor - */ - public static Map get() { - return HOLDER.get(); - } - - /** - * Returns the value of a single claim as a String, or {@code null} if - * the claim is absent or the request is not authenticated. - */ - public static String claim(String key) { - Map claims = HOLDER.get(); - if (claims == null) return null; - Object v = claims.get(key); - return v != null ? v.toString() : null; - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java new file mode 100644 index 0000000..36f6adf --- /dev/null +++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java @@ -0,0 +1,316 @@ +package dev.relism.flash.ext.oidc; + +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.ext.auth.CredentialSource; +import dev.relism.flash.models.Response; +import dev.relism.flash.models.Request; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * The OpenID Connect {@link CredentialSource}: it turns what a request carries into claims, and + * rejects it the way OAuth2 says to when it cannot. Authorization on those claims is + * {@code flash-ext-auth-core}'s job, not this class's. + * + *

    Resolution order on each request: + *

      + *
    1. {@code Authorization: Bearer ...} header — validated via JWKS ({@link JwtValidator}).
    2. + *
    3. {@code oidc_session} cookie — looked up in {@link OidcSessionStore}; transparently + * refreshed if the access token is expired.
    4. + *
    5. Browser clients (no {@code Accept: application/json}) → redirect to + * {@code {routePrefix}/login?redirect={path}}.
    6. + *
    7. API clients → 401 with a {@code WWW-Authenticate: Bearer} challenge.
    8. + *
    + */ +public final class OidcCredentialSource implements CredentialSource { + + 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 resourceMetadataPath; + + OidcCredentialSource(JwtValidator validator, OidcConfig config, + OidcProviderMetadata meta, TokenClient tokenClient) { + this(validator, config, meta, tokenClient, null); + } + + private OidcCredentialSource(JwtValidator validator, OidcConfig config, + OidcProviderMetadata meta, TokenClient tokenClient, + String resourceMetadataPath) { + this.validator = validator; + this.config = config; + this.meta = meta; + this.tokenClient = tokenClient; + this.resourceMetadataPath = resourceMetadataPath; + } + + // -- CredentialSource ----------------------------------------------------- + + /** OIDC issuer this source 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(); } + + /** + * A copy of this source whose 401 challenges also carry {@code resource_metadata} + * (RFC 9728 §5.1), resolved against the request's own scheme and host exactly like + * {@link OidcExtension}'s redirect URIs. {@code path} is absolute, e.g. + * {@code "/.well-known/oauth-protected-resource/mcp"}. + * + *

    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 OidcCredentialSource withResourceMetadata(String path) { + return new OidcCredentialSource(validator, config, meta, tokenClient, path); + } + + @Override + public Map authenticate(Request req, Response res) { + return resolve(req, res, resourceMetadataPath); + } + + @Override + public Map peek(Request req) { + return resolveQuiet(req); + } + + @Override + public String insufficientScopeChallenge(String[] requiredScopes) { + return bearerChallenge() + ", error=\"insufficient_scope\", scope=\"" + + quoted(spaceDelimited(requiredScopes)) + "\""; + } + + // -- 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 + ); + } + + 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\""; + } + + 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(); + } + + 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/main/java/dev/relism/flash/ext/oidc/OidcExtension.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java index 10502f7..133c152 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java +++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java @@ -4,6 +4,12 @@ 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.ext.auth.AuthConfig; +import dev.relism.flash.ext.auth.AuthMiddleware; +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 dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashRegistrar; @@ -28,7 +34,8 @@ import java.util.*; *

    At {@link #provide}, the extension: *

      *
    1. Fetches the provider discovery document — fail-fast at startup.
    2. - *
    3. Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.
    4. + *
    5. Provides {@link AuthMiddleware}, {@link OidcCredentialSource} and {@link JwtValidator} + * in the context.
    6. *
    7. Registers annotation processors for {@link Authenticated}, {@link RolesAllowed} * and {@link ScopesAllowed}.
    8. *
    @@ -56,7 +63,7 @@ import java.util.*; * }
*/ 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: - *

    - *
  1. {@code Authorization: Bearer ...} header — validated via JWKS ({@link JwtValidator}).
  2. - *
  3. {@code oidc_session} cookie — looked up in {@link OidcSessionStore}; transparently - * refreshed if the access token is expired.
  4. - *
  5. Browser clients (no {@code Accept: application/json}) → redirect to - * {@code {routePrefix}/login?redirect={path}}.
  6. - *
  7. API clients → 401.
  8. - *
- * - *
{@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