refactor(core): make boot and middleware ordering deterministic
CI / Build & Test (push) Failing after 4m51s
CI / Build & Test (pull_request) Canceled after 23s

This commit is contained in:
Zakaria El Orche
2026-08-12 16:42:49 +00:00
parent d7f36a7aea
commit 891ef99b8e
51 changed files with 1395 additions and 504 deletions
@@ -7,6 +7,8 @@ import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.models.Request;
import javax.net.ssl.SSLContext;
@@ -54,6 +56,7 @@ import java.util.*;
* }</pre>
*/
public class OidcExtension implements FlashExtension {
private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.oidc.policy");
private final OidcConfig config;
@@ -71,7 +74,7 @@ public class OidcExtension implements FlashExtension {
// ── Phase 1: services ─────────────────────────────────────────────────────
@Override
public void provide(FlashContext ctx) {
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
HttpClient http = buildHttpClient(config);
// Discover provider endpoints (blocking; fail fast at startup).
@@ -91,14 +94,12 @@ public class OidcExtension implements FlashExtension {
ctx.addAnnotationProcessor(handlerClass -> {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
return policy != null ? List.of(oidcMw.policyMiddleware(policy)) : List.of();
return policy != null ? List.of(MiddlewareNode.of(POLICY, oidcMw.policyMiddleware(policy))) : List.of();
});
ctx.onReady(() -> registerRoutes(app, ctx));
}
// ── Phase 2: routes ───────────────────────────────────────────────────────
@Override
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
String prefix = config.routePrefix();
// ── GET {prefix}/login ────────────────────────────────────────────────
@@ -252,7 +253,7 @@ public class OidcExtension implements FlashExtension {
private String absoluteSelf(Request req, String uri) {
if (!uri.startsWith("/")) return uri;
return config.selfScheme() + "://" + req.header("Host") + uri;
return OidcMiddleware.selfOrigin(req, config.selfScheme()) + uri;
}
private static String enc(String v) {
@@ -65,8 +65,21 @@ public class OidcMiddleware {
* 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);
Map<String, Object> claims = resolve(req, res, resourceMetadataPath);
if (claims == null) return null; // redirect already written
ClaimsHolder.set(claims);
try {
@@ -77,6 +90,12 @@ public class OidcMiddleware {
};
}
/** 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
@@ -195,13 +214,17 @@ public class OidcMiddleware {
* {@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());
res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath));
throw e;
}
}
@@ -233,7 +256,7 @@ public class OidcMiddleware {
// 3. No valid credentials
String accept = req.header("Accept");
if (accept != null && accept.contains("application/json")) {
res.header("WWW-Authenticate", bearerChallenge());
res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath));
throw HttpException.unauthorized();
}
@@ -300,11 +323,21 @@ public class OidcMiddleware {
}
String bearerChallenge() {
return BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
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 bearerChallenge() + ", error=\"invalid_token\"";
return invalidTokenChallenge(null, null);
}
private String invalidTokenChallenge(Request req, String resourceMetadataPath) {
return bearerChallenge(req, resourceMetadataPath) + ", error=\"invalid_token\"";
}
String insufficientScopeChallenge(String[] requiredScopes) {
@@ -312,6 +345,28 @@ public class OidcMiddleware {
+ 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();
@@ -104,6 +104,7 @@ class OidcOpenApiInteropTest {
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
ctx.complete();
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");