refactor(core): make boot and middleware ordering deterministic
This commit is contained in:
+8
-7
@@ -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) {
|
||||
|
||||
+60
-5
@@ -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();
|
||||
|
||||
+1
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user