refactor(ext-auth): extract flash-ext-auth-core out of flash-ext-oidc
flash-ext-oidc has always held two things: the OpenID Connect protocol, and a session/claims/authorization layer that is generic and was only ever fed by one source. This splits them along Flash's own <domain>-core convention, the same shape cache-core, data-core and view-core already use. flash-ext-auth-core gets what never referenced the protocol — @Authenticated, @RolesAllowed, @ScopesAllowed, ClaimsHolder, the claim matching, and the policy compiled from annotations — under generic names: OidcUser is Claims, since it never was more than a typed view over a claims map, and OidcAuthPolicy is AuthPolicy. It was package-private while being the parameter type of a public method, so the move also fixes that. The new seam is CredentialSource: it resolves a request's claims, or rejects the request the way its protocol says to. AuthMiddleware publishes the result and matches roles and scopes against it. ClaimsHolder's writers stay package-private — an implementation produces claims and core publishes them, so nothing outside this module can put claims on a request that did not carry them. flash-ext-oidc keeps discovery, JWKS, PKCE, the token endpoint, the login and callback routes and the OpenAPI oauth2 contributor, and now registers OidcCredentialSource. flash-ext-mcp still keys McpSecurity on finding that type and not on AuthMiddleware: REQUIRED has to keep meaning "a real authorization server is protecting this endpoint", not "something authenticates here". The middleware key moves with the mechanism: flash.oidc.policy -> flash.auth.policy. Breaking for consumers: imports move to dev.relism.flash.ext.auth, OidcMiddleware becomes AuthMiddleware, ClaimsHolder.user()/get() become current()/map().
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-auth-core</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package dev.relism.flash.ext.auth;
|
||||
|
||||
/**
|
||||
* Where authorization reads its inputs from. Deliberately small: everything about *obtaining* a
|
||||
* credential belongs to the {@link CredentialSource} that produced it, and everything about
|
||||
* *checking* one is right here.
|
||||
*
|
||||
* <p>Defaults are the generic spelling, not any one provider's. A source that knows better —
|
||||
* {@code flash-ext-auth-oidc} defaults roles to Keycloak's {@code realm_access.roles} — builds
|
||||
* its own {@code AuthConfig} with the paths its provider actually uses.
|
||||
*/
|
||||
public final class AuthConfig {
|
||||
|
||||
private final String rolesClaimPath;
|
||||
private final String scopeClaimPaths;
|
||||
|
||||
private AuthConfig(Builder b) {
|
||||
this.rolesClaimPath = b.rolesClaimPath;
|
||||
this.scopeClaimPaths = b.scopeClaimPaths;
|
||||
}
|
||||
|
||||
/** Dot-separated path to the roles list in the claims (default: {@code roles}). */
|
||||
public String rolesClaimPath() { return rolesClaimPath; }
|
||||
|
||||
/** Comma-separated claim paths scopes are read from, in order (default: {@code scope,scp}). */
|
||||
public String scopeClaimPaths() { return scopeClaimPaths; }
|
||||
|
||||
public static Builder builder() { return new Builder(); }
|
||||
|
||||
public static final class Builder {
|
||||
private String rolesClaimPath = "roles";
|
||||
private String scopeClaimPaths = "scope,scp";
|
||||
|
||||
private Builder() {}
|
||||
|
||||
/** Dot-separated path to the roles list — e.g. {@code realm_access.roles}, {@code groups}. */
|
||||
public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
|
||||
/** Comma-separated claim paths scopes are read from, tried in order. */
|
||||
public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; }
|
||||
|
||||
public AuthConfig build() { return new AuthConfig(this); }
|
||||
}
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
package dev.relism.flash.ext.auth;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Turns claims into a yes or a no. Exposed in the {@link FlashContext} for manual use on lambda
|
||||
* routes, and injected automatically for handlers annotated with {@link Authenticated},
|
||||
* {@link RolesAllowed} or {@link ScopesAllowed}.
|
||||
*
|
||||
* <p>It knows nothing about how the caller proved who they are — that is the
|
||||
* {@link CredentialSource} it is built with. What lives here is the half that is the same for
|
||||
* every mechanism: publish the claims for the request, match roles and scopes against them, clear
|
||||
* up afterwards.
|
||||
*
|
||||
* <pre>{@code
|
||||
* AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
|
||||
* app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect());
|
||||
* app.delete("/admin/users/{id}", handler, auth.requireRole("admin"));
|
||||
* }</pre>
|
||||
*/
|
||||
public class AuthMiddleware {
|
||||
|
||||
private final AuthConfig config;
|
||||
private final CredentialSource source;
|
||||
private final String[] roleClaimPathParts;
|
||||
private final String[][] scopeClaimPathParts;
|
||||
|
||||
public AuthMiddleware(AuthConfig config, CredentialSource source) {
|
||||
this.config = config;
|
||||
this.source = source;
|
||||
this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath());
|
||||
this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths());
|
||||
}
|
||||
|
||||
// -- Public API -----------------------------------------------------------
|
||||
|
||||
/** The single configured claim path used by every transport for role checks. */
|
||||
public String rolesClaimPath() { return config.rolesClaimPath(); }
|
||||
|
||||
/** The source this middleware authenticates with. */
|
||||
public CredentialSource source() { return source; }
|
||||
|
||||
/**
|
||||
* The same authorization rules against a different credential source. Used where one route
|
||||
* needs a variant of an installed source — {@code flash-ext-mcp} protects {@code /mcp} with an
|
||||
* OIDC source whose challenges carry RFC 9728 resource metadata, while every other route keeps
|
||||
* the plain one.
|
||||
*/
|
||||
public AuthMiddleware withSource(CredentialSource source) {
|
||||
return new AuthMiddleware(config, source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects the request unless the caller is authenticated. How it is rejected — a 401 with a
|
||||
* challenge, a redirect into a sign-in flow — is the source's decision, not this one's.
|
||||
*/
|
||||
public Middleware protect() {
|
||||
return next -> (req, res) -> {
|
||||
Map<String, Object> claims = source.authenticate(req, res);
|
||||
if (claims == null) return null; // the source already answered the request
|
||||
ClaimsHolder.set(claims);
|
||||
try {
|
||||
return next.handle(req, res);
|
||||
} finally {
|
||||
ClaimsHolder.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes claims when the caller happens to be authenticated and never rejects anyone. Use
|
||||
* it on public routes that personalise their response for signed-in callers.
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.get("/", handler, auth.optional());
|
||||
* // Inside handler: ClaimsHolder.current() is non-null iff the caller is signed in.
|
||||
* }</pre>
|
||||
*/
|
||||
public Middleware optional() {
|
||||
return next -> (req, res) -> {
|
||||
Map<String, Object> claims = source.peek(req);
|
||||
if (claims != null) ClaimsHolder.set(claims);
|
||||
try {
|
||||
return next.handle(req, res);
|
||||
} finally {
|
||||
ClaimsHolder.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a policy compiled once at boot from a handler's annotations. This is the path
|
||||
* annotation-driven mounting takes.
|
||||
*/
|
||||
public Middleware authorize(AuthPolicy policy) {
|
||||
if (policy.optionalAuth()) return optional();
|
||||
return next -> (req, res) -> {
|
||||
Map<String, Object> claims = source.authenticate(req, res);
|
||||
if (claims == null) return null;
|
||||
enforcePolicy(claims, policy, res);
|
||||
ClaimsHolder.set(claims);
|
||||
try {
|
||||
return next.handle(req, res);
|
||||
} finally {
|
||||
ClaimsHolder.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** {@link #protect()} plus at least one of the given roles (OR semantics). */
|
||||
public Middleware requireRole(String... roles) {
|
||||
return authorize(AuthPolicy.rolesAny(roles));
|
||||
}
|
||||
|
||||
/** {@link #protect()} plus every one of the given scopes. */
|
||||
public Middleware requireScopes(String... scopes) {
|
||||
return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL));
|
||||
}
|
||||
|
||||
/** {@link #protect()} plus at least one of the given scopes. */
|
||||
public Middleware requireAnyScope(String... scopes) {
|
||||
return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY));
|
||||
}
|
||||
|
||||
// -- Policy enforcement ---------------------------------------------------
|
||||
|
||||
private void enforcePolicy(Map<String, Object> claims, AuthPolicy policy, Response res) {
|
||||
checkRoles(claims, policy.requiredRoles());
|
||||
checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res);
|
||||
}
|
||||
|
||||
private void checkRoles(Map<String, Object> claims, String[] required) {
|
||||
if (required.length == 0) return;
|
||||
if (rolesAllowed(claims, required)) return;
|
||||
throw HttpException.forbidden();
|
||||
}
|
||||
|
||||
private void checkScopes(Map<String, Object> claims, String[] required, ScopesAllowed.Match match,
|
||||
Response res) {
|
||||
if (required.length == 0) return;
|
||||
if (scopesAllowed(claims, required, match)) return;
|
||||
String challenge = source.insufficientScopeChallenge(required);
|
||||
if (challenge != null) res.header("WWW-Authenticate", challenge);
|
||||
throw HttpException.forbidden();
|
||||
}
|
||||
|
||||
// -- Claim matching -------------------------------------------------------
|
||||
|
||||
boolean rolesAllowed(Map<String, Object> claims, String[] required) {
|
||||
Object actual = valueAtPath(claims, roleClaimPathParts);
|
||||
if (actual == null) return false;
|
||||
for (String role : required) {
|
||||
if (containsToken(actual, role)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean scopesAllowed(Map<String, Object> claims, String[] required, ScopesAllowed.Match match) {
|
||||
if (match == ScopesAllowed.Match.ALL) {
|
||||
for (String scope : required) {
|
||||
if (!hasScope(claims, scope)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
for (String scope : required) {
|
||||
if (hasScope(claims, scope)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean hasScope(Map<String, Object> claims, String scope) {
|
||||
for (String[] pathParts : scopeClaimPathParts) {
|
||||
Object value = valueAtPath(claims, pathParts);
|
||||
if (value != null && containsToken(value, scope)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Object valueAtPath(Map<String, Object> claims, String[] pathParts) {
|
||||
Object current = claims;
|
||||
for (String part : pathParts) {
|
||||
if (!(current instanceof Map<?, ?> map)) return null;
|
||||
current = map.get(part);
|
||||
if (current == null) return null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static boolean containsToken(Object source, String token) {
|
||||
if (source instanceof String s) return containsDelimitedToken(s, token);
|
||||
if (source instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
if (item == null) continue;
|
||||
if (tokenEquals(item.toString(), token)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (source instanceof Object[] arr) {
|
||||
for (Object item : arr) {
|
||||
if (item == null) continue;
|
||||
if (tokenEquals(item.toString(), token)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return tokenEquals(source.toString(), token);
|
||||
}
|
||||
|
||||
private static boolean containsDelimitedToken(String value, String token) {
|
||||
int len = value.length();
|
||||
int i = 0;
|
||||
while (i < len) {
|
||||
while (i < len && isScopeDelimiter(value.charAt(i))) i++;
|
||||
int start = i;
|
||||
while (i < len && !isScopeDelimiter(value.charAt(i))) i++;
|
||||
int end = i;
|
||||
if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean tokenEquals(String value, String token) {
|
||||
int start = 0;
|
||||
int end = value.length();
|
||||
while (start < end && Character.isWhitespace(value.charAt(start))) start++;
|
||||
while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--;
|
||||
return end - start == token.length() && value.regionMatches(start, token, 0, token.length());
|
||||
}
|
||||
|
||||
private static boolean isScopeDelimiter(char c) {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
|
||||
}
|
||||
|
||||
private static String[] splitClaimPath(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
throw new IllegalStateException("Claim path cannot be blank");
|
||||
}
|
||||
List<String> parts = new ArrayList<>(4);
|
||||
int start = 0;
|
||||
int len = path.length();
|
||||
for (int i = 0; i <= len; i++) {
|
||||
if (i == len || path.charAt(i) == '.') {
|
||||
String p = path.substring(start, i).trim();
|
||||
if (!p.isEmpty()) parts.add(p);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if (parts.isEmpty()) {
|
||||
throw new IllegalStateException("Claim path cannot be blank");
|
||||
}
|
||||
return parts.toArray(String[]::new);
|
||||
}
|
||||
|
||||
private static String[][] splitClaimPaths(String paths) {
|
||||
String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths;
|
||||
List<String[]> out = new ArrayList<>(4);
|
||||
int start = 0;
|
||||
int len = source.length();
|
||||
for (int i = 0; i <= len; i++) {
|
||||
if (i == len || source.charAt(i) == ',') {
|
||||
String raw = source.substring(start, i).trim();
|
||||
if (!raw.isEmpty()) out.add(splitClaimPath(raw));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if (out.isEmpty()) {
|
||||
return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") };
|
||||
}
|
||||
return out.toArray(String[][]::new);
|
||||
}
|
||||
}
|
||||
+18
-18
@@ -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<String> openApiScopesFor(Class<?> handlerClass) {
|
||||
public static List<String> openApiScopesFor(Class<?> handlerClass) {
|
||||
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
||||
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
||||
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
||||
@@ -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)
|
||||
+9
-9
@@ -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.
|
||||
*
|
||||
* <p>For role-based access use {@link RolesAllowed} instead (it implies authentication).
|
||||
*
|
||||
* <p>Set {@code optional = true} on public routes that personalise their response when
|
||||
* the 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.
|
||||
* <p>Set {@code optional = true} on public routes that personalise their response when the caller
|
||||
* happens to be signed in but should remain reachable by guests. The middleware populates
|
||||
* {@link ClaimsHolder} when a credential is present and silently skips it otherwise — the request
|
||||
* is never rejected.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Hard auth — 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 { ... }
|
||||
+15
-22
@@ -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()}.
|
||||
*
|
||||
* <p>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.
|
||||
* <p>The accessors name claim <em>keys</em>, not a protocol: {@code sub} is RFC 7519, and
|
||||
* {@code email}, {@code name} and {@code preferred_username} are spelled the same way by every
|
||||
* token issuer worth integrating. A source that uses different keys exposes them through
|
||||
* {@link #claim(String)} or {@link #roles(String)}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // 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());
|
||||
* }</pre>
|
||||
*/
|
||||
public final class OidcUser {
|
||||
public final class Claims {
|
||||
|
||||
private final Map<String, Object> claims;
|
||||
|
||||
OidcUser(Map<String, Object> claims) {
|
||||
Claims(Map<String, Object> 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<String> scopes() {
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package dev.relism.flash.ext.auth;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The current request's claims, published by {@link AuthMiddleware} before the handler runs and
|
||||
* cleared in a {@code finally} afterwards.
|
||||
*
|
||||
* <p>Safe with virtual threads: each request gets its own, so a {@link ThreadLocal} is naturally
|
||||
* isolated per request.
|
||||
*
|
||||
* <p>Writing is deliberately not public. A {@link CredentialSource} returns claims and the
|
||||
* middleware publishes them, so no code outside this module can put claims on a request that did
|
||||
* not carry them.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Inside any handler behind @Authenticated or @RolesAllowed:
|
||||
* Claims caller = ClaimsHolder.current();
|
||||
* String email = caller.email();
|
||||
* List<String> roles = caller.roles("realm_access.roles");
|
||||
*
|
||||
* // Raw escape hatch:
|
||||
* Map<String, Object> all = ClaimsHolder.map();
|
||||
* }</pre>
|
||||
*/
|
||||
public final class ClaimsHolder {
|
||||
|
||||
private static final ThreadLocal<Map<String, Object>> HOLDER = new ThreadLocal<>();
|
||||
|
||||
private ClaimsHolder() {}
|
||||
|
||||
/** Called by {@link AuthMiddleware} once a source has authenticated the request. */
|
||||
static void set(Map<String, Object> claims) {
|
||||
HOLDER.set(claims);
|
||||
}
|
||||
|
||||
/** Called by {@link AuthMiddleware} in the {@code finally} block. */
|
||||
static void clear() {
|
||||
HOLDER.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed view of the current request's claims, or {@code null} when the route carries no
|
||||
* authentication middleware or the caller is anonymous under
|
||||
* {@link Authenticated}{@code (optional = true)}.
|
||||
*/
|
||||
public static Claims current() {
|
||||
Map<String, Object> claims = HOLDER.get();
|
||||
return claims != null ? new Claims(claims) : null;
|
||||
}
|
||||
|
||||
/** The raw claims map for the current request, or {@code null}. @see #current() */
|
||||
public static Map<String, Object> map() {
|
||||
return HOLDER.get();
|
||||
}
|
||||
|
||||
/** A single claim as a String, or {@code null} when absent or the caller is anonymous. */
|
||||
public static String claim(String key) {
|
||||
Map<String, Object> claims = HOLDER.get();
|
||||
if (claims == null) return null;
|
||||
Object v = claims.get(key);
|
||||
return v != null ? v.toString() : null;
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.relism.flash.ext.auth;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Turns whatever a request carries — a bearer token, a session cookie, an API key — into the
|
||||
* claims authorization runs on. One is installed per authentication mechanism;
|
||||
* {@code flash-ext-auth-oidc} contributes the OpenID Connect one.
|
||||
*
|
||||
* <p>Implementations never touch {@link ClaimsHolder}: they produce claims and {@link
|
||||
* AuthMiddleware} publishes them for the duration of the request. Nothing outside this module can
|
||||
* inject claims into a request, which is the point.
|
||||
*/
|
||||
public interface CredentialSource {
|
||||
|
||||
/**
|
||||
* Resolves the caller's claims, rejecting the request when it cannot.
|
||||
*
|
||||
* <p>Three outcomes, and the difference between the last two matters:
|
||||
* <ul>
|
||||
* <li>claims — the caller presented a valid credential;</li>
|
||||
* <li>{@code null} — no credential was presented and this source has already answered the
|
||||
* request itself (typically a redirect into a sign-in flow). The middleware stops and
|
||||
* writes nothing more;</li>
|
||||
* <li>{@link HttpException} — a credential <em>was</em> presented and is invalid. The source
|
||||
* sets any challenge header it owes the caller before throwing.</li>
|
||||
* </ul>
|
||||
*/
|
||||
Map<String, Object> authenticate(Request req, Response res);
|
||||
|
||||
/**
|
||||
* Resolves claims without ever rejecting: {@code null} when no valid credential is present.
|
||||
* Backs {@link Authenticated}{@code (optional = true)}, where an anonymous caller is a normal
|
||||
* outcome rather than a failure.
|
||||
*/
|
||||
Map<String, Object> peek(Request req);
|
||||
|
||||
/**
|
||||
* The {@code WWW-Authenticate} value to send with a 403 caused by missing scopes, or
|
||||
* {@code null} when this source has no such concept. Only consulted after authentication has
|
||||
* already succeeded.
|
||||
*/
|
||||
default String insufficientScopeChallenge(String[] requiredScopes) { return null; }
|
||||
}
|
||||
+7
-8
@@ -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}.
|
||||
*
|
||||
* <p>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.
|
||||
* <p>Roles are read from the claim path the installed credential source is configured with
|
||||
* (Keycloak's is {@code realm_access.roles}; many providers use a flat {@code roles} or
|
||||
* {@code groups}). Nested paths use dot notation.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
|
||||
* @RolesAllowed("admin")
|
||||
* public class DeleteBlog extends JacksonHandler { ... }
|
||||
*
|
||||
* // Multiple accepted roles (OR semantics — any one role is sufficient):
|
||||
* // Multiple accepted roles (OR semantics — any one is sufficient):
|
||||
* @RolesAllowed({"admin", "editor"})
|
||||
* public class UpdateBlog extends JacksonHandler { ... }
|
||||
* }</pre>
|
||||
+3
-3
@@ -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.
|
||||
*
|
||||
* <p>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:
|
||||
* <ul>
|
||||
* <li>{@code scope}: space-separated string</li>
|
||||
+11
-11
@@ -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));
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -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<String, Object> 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));
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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")
|
||||
));
|
||||
+26
-20
@@ -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}.
|
||||
*
|
||||
* <p>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;
|
||||
* <p>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<OidcMiddleware> 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<OidcCredentialSource> oidc = ctx.find(OidcCredentialSource.class);
|
||||
Optional<AuthMiddleware> 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<Request, String> 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<Request, String> resourceIdentifier) {
|
||||
return next -> (req, res) -> {
|
||||
Map<String, Object> claims = ClaimsHolder.get();
|
||||
Map<String, Object> 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.
|
||||
*
|
||||
* <p>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<String> 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;
|
||||
|
||||
+1
-1
@@ -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.
|
||||
*/
|
||||
|
||||
+1
-1
@@ -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. */
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
<artifactId>flash-ext-oidc</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-auth-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
|
||||
-71
@@ -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.
|
||||
*
|
||||
* <p>Safe with virtual threads: each request gets its own virtual thread, so
|
||||
* {@link ThreadLocal} values are naturally isolated per request.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Inside any handler protected by @Authenticated or @RolesAllowed:
|
||||
*
|
||||
* // Preferred — typed wrapper:
|
||||
* OidcUser user = ClaimsHolder.user();
|
||||
* String email = user.email();
|
||||
* List<String> roles = user.roles("realm_access.roles");
|
||||
*
|
||||
* // Raw escape hatch:
|
||||
* Map<String, Object> all = ClaimsHolder.get();
|
||||
* }</pre>
|
||||
*/
|
||||
public final class ClaimsHolder {
|
||||
|
||||
private static final ThreadLocal<Map<String, Object>> HOLDER = new ThreadLocal<>();
|
||||
|
||||
private ClaimsHolder() {}
|
||||
|
||||
/** Called by the OIDC middleware after successful token validation. */
|
||||
static void set(Map<String, Object> 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.
|
||||
*
|
||||
* <p>This is the preferred entry point for both lambda and class-based handlers.
|
||||
*/
|
||||
public static OidcUser user() {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> claims = HOLDER.get();
|
||||
if (claims == null) return null;
|
||||
Object v = claims.get(key);
|
||||
return v != null ? v.toString() : null;
|
||||
}
|
||||
}
|
||||
+316
@@ -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.
|
||||
*
|
||||
* <p>Resolution order on each request:
|
||||
* <ol>
|
||||
* <li>{@code Authorization: Bearer ...} header — validated via JWKS ({@link JwtValidator}).</li>
|
||||
* <li>{@code oidc_session} cookie — looked up in {@link OidcSessionStore}; transparently
|
||||
* refreshed if the access token is expired.</li>
|
||||
* <li>Browser clients (no {@code Accept: application/json}) → redirect to
|
||||
* {@code {routePrefix}/login?redirect={path}}.</li>
|
||||
* <li>API clients → 401 with a {@code WWW-Authenticate: Bearer} challenge.</li>
|
||||
* </ol>
|
||||
*/
|
||||
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"}.
|
||||
*
|
||||
* <p>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<String, Object> authenticate(Request req, Response res) {
|
||||
return resolve(req, res, resourceMetadataPath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> 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<String, Object> 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<OidcSession> 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<String, Object> resolve(Request req, Response res) {
|
||||
return resolve(req, res, null);
|
||||
}
|
||||
|
||||
private Map<String, Object> 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<OidcSession> 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<String, Object> 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<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) {
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
+25
-12
@@ -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.*;
|
||||
* <p>At {@link #provide}, the extension:
|
||||
* <ol>
|
||||
* <li>Fetches the provider discovery document — fail-fast at startup.</li>
|
||||
* <li>Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.</li>
|
||||
* <li>Provides {@link AuthMiddleware}, {@link OidcCredentialSource} and {@link JwtValidator}
|
||||
* in the context.</li>
|
||||
* <li>Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
|
||||
* and {@link ScopesAllowed}.</li>
|
||||
* </ol>
|
||||
@@ -56,7 +63,7 @@ import java.util.*;
|
||||
* }</pre>
|
||||
*/
|
||||
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<String> operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
|
||||
List<String> 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"));
|
||||
|
||||
-550
@@ -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}.
|
||||
*
|
||||
* <p>Resolution order on each request:
|
||||
* <ol>
|
||||
* <li>{@code Authorization: Bearer ...} header — validated via JWKS ({@link JwtValidator}).</li>
|
||||
* <li>{@code oidc_session} cookie — looked up in {@link OidcSessionStore}; transparently
|
||||
* refreshed if the access token is expired.</li>
|
||||
* <li>Browser clients (no {@code Accept: application/json}) → redirect to
|
||||
* {@code {routePrefix}/login?redirect={path}}.</li>
|
||||
* <li>API clients → 401.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <pre>{@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"));
|
||||
* }</pre>
|
||||
*/
|
||||
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<String, Object> 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).
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.get("/", handler, oidc.optional());
|
||||
* // Inside handler: ClaimsHolder.user() is non-null iff the user is logged in.
|
||||
* }</pre>
|
||||
*/
|
||||
public Middleware optional() {
|
||||
return next -> (req, res) -> {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<OidcSession> 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<String, Object> resolve(Request req, Response res) {
|
||||
return resolve(req, res, null);
|
||||
}
|
||||
|
||||
private Map<String, Object> 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<OidcSession> 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<String, Object> 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<String, Object> claims, OidcAuthPolicy policy, Response res) {
|
||||
checkRoles(claims, policy.requiredRoles());
|
||||
checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res);
|
||||
}
|
||||
|
||||
private void checkRoles(Map<String, Object> claims, String[] required) {
|
||||
if (required.length == 0) return;
|
||||
if (rolesAllowed(claims, required)) return;
|
||||
throw HttpException.forbidden();
|
||||
}
|
||||
|
||||
private void checkScopes(Map<String, Object> claims, String[] required, ScopesAllowed.Match match,
|
||||
Response res) {
|
||||
if (required.length == 0) return;
|
||||
if (scopesAllowed(claims, required, match)) return;
|
||||
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<String, Object> claims, String[] required) {
|
||||
Object actual = valueAtPath(claims, roleClaimPathParts);
|
||||
if (actual == null) return false;
|
||||
for (String role : required) {
|
||||
if (containsToken(actual, role)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean scopesAllowed(Map<String, Object> claims, String[] required, ScopesAllowed.Match match) {
|
||||
if (match == ScopesAllowed.Match.ALL) {
|
||||
for (String scope : required) {
|
||||
if (!hasScope(claims, scope)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
for (String scope : required) {
|
||||
if (hasScope(claims, scope)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean hasScope(Map<String, Object> claims, String scope) {
|
||||
for (String[] pathParts : scopeClaimPathParts) {
|
||||
Object value = valueAtPath(claims, pathParts);
|
||||
if (value != null && containsToken(value, scope)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Object valueAtPath(Map<String, Object> claims, String[] pathParts) {
|
||||
Object current = claims;
|
||||
for (String part : pathParts) {
|
||||
if (!(current instanceof Map<?, ?> map)) return null;
|
||||
current = map.get(part);
|
||||
if (current == null) return null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static boolean containsToken(Object source, String token) {
|
||||
if (source instanceof String s) return containsDelimitedToken(s, token);
|
||||
if (source instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
if (item == null) continue;
|
||||
if (tokenEquals(item.toString(), token)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (source instanceof Object[] arr) {
|
||||
for (Object item : arr) {
|
||||
if (item == null) continue;
|
||||
if (tokenEquals(item.toString(), token)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return tokenEquals(source.toString(), token);
|
||||
}
|
||||
|
||||
private static boolean containsDelimitedToken(String value, String token) {
|
||||
int len = value.length();
|
||||
int i = 0;
|
||||
while (i < len) {
|
||||
while (i < len && isScopeDelimiter(value.charAt(i))) i++;
|
||||
int start = i;
|
||||
while (i < len && !isScopeDelimiter(value.charAt(i))) i++;
|
||||
int end = i;
|
||||
if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean tokenEquals(String value, String token) {
|
||||
int start = 0;
|
||||
int end = value.length();
|
||||
while (start < end && Character.isWhitespace(value.charAt(start))) start++;
|
||||
while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--;
|
||||
return end - start == token.length() && value.regionMatches(start, token, 0, token.length());
|
||||
}
|
||||
|
||||
private static boolean isScopeDelimiter(char c) {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
|
||||
}
|
||||
|
||||
private static String[] splitClaimPath(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
throw new IllegalStateException("OIDC claim path cannot be blank");
|
||||
}
|
||||
List<String> parts = new ArrayList<>(4);
|
||||
int start = 0;
|
||||
int len = path.length();
|
||||
for (int i = 0; i <= len; i++) {
|
||||
if (i == len || path.charAt(i) == '.') {
|
||||
String p = path.substring(start, i).trim();
|
||||
if (!p.isEmpty()) parts.add(p);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if (parts.isEmpty()) {
|
||||
throw new IllegalStateException("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<String[]> out = new ArrayList<>(4);
|
||||
int start = 0;
|
||||
int len = source.length();
|
||||
for (int i = 0; i <= len; i++) {
|
||||
if (i == len || source.charAt(i) == ',') {
|
||||
String raw = source.substring(start, i).trim();
|
||||
if (!raw.isEmpty()) out.add(splitClaimPath(raw));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if (out.isEmpty()) {
|
||||
return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") };
|
||||
}
|
||||
return out.toArray(String[][]::new);
|
||||
}
|
||||
|
||||
private static Map<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) {
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
+43
@@ -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\""));
|
||||
}
|
||||
}
|
||||
-72
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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\""));
|
||||
}
|
||||
}
|
||||
+4
@@ -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;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<modules>
|
||||
<module>flash-ext-jackson</module>
|
||||
<module>flash-ext-openapi</module>
|
||||
<module>flash-ext-auth-core</module>
|
||||
<module>flash-ext-oidc</module>
|
||||
<module>flash-ext-routeviewer</module>
|
||||
<module>flash-ext-view-core</module>
|
||||
@@ -45,6 +46,11 @@
|
||||
<artifactId>flash-ext-scheduler</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-auth-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-cache-core</artifactId>
|
||||
|
||||
Reference in New Issue
Block a user