feat(ext-security): add an OAuth 2.1 authorization server

flash-ext-security-oauth-server issues RFC 9068 access tokens (code + PKCE S256,
CIMD and DCR clients, RFC 8707 resources, rotating refresh tokens) for resources on
the application's own origin. Around it: SecurityExtension resolves a configured
origin instead of X-Forwarded-* headers, mechanisms expose schemes() and a route can
be restricted to some of them, McpConfig.mechanisms(...) uses that, OIDC bearers must
be typed at+jwt, and PublicUrl guards outbound fetches against internal addresses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-22 11:39:09 +00:00
co-authored by Claude Opus 5
parent 7f225e0faf
commit f28fc43150
35 changed files with 1958 additions and 80 deletions
@@ -1,5 +1,6 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.security.AuthenticationMechanism;
import dev.relism.flash.routing.Middleware;
import java.util.ArrayList;
@@ -30,6 +31,7 @@ public final class McpConfig {
private final List<String> allowedOrigins;
private final List<String> scopesSupported;
private final List<Middleware> middleware;
private final List<AuthenticationMechanism> mechanisms;
private McpConfig(Builder b) {
this.name = b.name;
@@ -42,6 +44,7 @@ public final class McpConfig {
this.allowedOrigins = List.copyOf(b.allowedOrigins);
this.scopesSupported = List.copyOf(b.scopesSupported);
this.middleware = List.copyOf(b.middleware);
this.mechanisms = List.copyOf(b.mechanisms);
}
String name() { return name; }
@@ -54,6 +57,7 @@ public final class McpConfig {
List<String> allowedOrigins() { return allowedOrigins; }
List<String> scopesSupported() { return scopesSupported; }
List<Middleware> middleware() { return middleware; }
List<AuthenticationMechanism> mechanisms() { return mechanisms; }
public static Builder builder(String name) { return new Builder(name); }
@@ -68,6 +72,7 @@ public final class McpConfig {
private final List<String> allowedOrigins = new ArrayList<>();
private final List<String> scopesSupported = new ArrayList<>();
private final List<Middleware> middleware = new ArrayList<>();
private final List<AuthenticationMechanism> mechanisms = new ArrayList<>();
private Builder(String name) {
if (name == null || name.isBlank())
@@ -108,6 +113,15 @@ public final class McpConfig {
/** Published as {@code scopes_supported} in the RFC 9728 metadata, so OAuth clients request them. */
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
/**
* The only mechanisms that authenticate the endpoint, and the only issuers its RFC 9728 metadata
* names: any other credential, the session cookie included, is none here. Default: the whole chain.
*/
public Builder mechanisms(AuthenticationMechanism... mechanisms) {
this.mechanisms.addAll(List.of(mechanisms));
return this;
}
/** Runs on the MCP route after the transport guards and authentication — rate limiting, auditing, tracing. */
public Builder middleware(Middleware... middleware) {
this.middleware.addAll(List.of(middleware));
@@ -1,6 +1,8 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.security.AuthenticationEntryPoint;
import dev.relism.flash.ext.security.AuthenticationMechanism;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.ext.security.SecurityPolicy;
@@ -38,6 +40,7 @@ import java.util.Objects;
public class McpExtension implements FlashExtension {
private final McpConfig config;
private volatile List<SecurityScheme> schemes;
public McpExtension(McpConfig config) {
this.config = config;
@@ -65,16 +68,19 @@ public class McpExtension implements FlashExtension {
private void protect(FlashRegistrar<?> app, SecurityExtension security, List<Middleware> chain) {
String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
chain.add(security.enforce(SecurityPolicy.AUTHENTICATED, (req, res) -> {
List<String> issuers = issuers(security);
res.header("WWW-Authenticate", issuers.isEmpty()
? String.join(", ", security.schemes().stream().map(SecurityScheme::challenge).toList())
: "Bearer resource_metadata=\"" + req.origin() + metadataPath + "\"");
List<AuthenticationMechanism> only = config.mechanisms();
AuthenticationEntryPoint anonymous = (req, res) -> {
List<SecurityScheme> schemes = schemes(security);
res.header("WWW-Authenticate", issuers(schemes, null).isEmpty()
? String.join(", ", schemes.stream().map(SecurityScheme::challenge).toList())
: "Bearer resource_metadata=\"" + security.origin(req) + metadataPath + "\"");
throw HttpException.unauthorized();
}));
};
chain.add(only.isEmpty() ? security.enforce(SecurityPolicy.AUTHENTICATED, anonymous)
: security.enforce(SecurityPolicy.AUTHENTICATED, anonymous, only));
if (config.requireTokenAudience()) {
chain.add(next -> (req, res) -> {
String resource = req.origin() + config.rootPath();
String resource = security.origin(req) + config.rootPath();
if (!SecurityIdentity.current().principal().hasAudience(resource)) {
log.warn("[flash-ext-mcp] Rejected a token not issued for {} (RFC 8707) — the authorization server must put it in aud", resource);
throw HttpException.forbidden();
@@ -85,14 +91,24 @@ public class McpExtension implements FlashExtension {
log.warn("[flash-ext-mcp] Token audience validation (RFC 8707) is DISABLED for {} — every token a registered issuer signs is accepted.", config.rootPath());
}
app.get(metadataPath, (req, res) -> {
List<String> issuers = issuers(security);
List<String> issuers = issuers(schemes(security), security.origin(req));
if (issuers.isEmpty()) throw HttpException.notFound("Protected resource metadata");
res.type(ContentType.JSON);
return McpResourceMetadata.build(req.origin() + config.rootPath(), issuers, config.scopesSupported());
return McpResourceMetadata.build(security.origin(req) + config.rootPath(), issuers, config.scopesSupported());
});
}
private static List<String> issuers(SecurityExtension security) {
return security.schemes().stream().map(SecurityScheme::issuer).filter(Objects::nonNull).toList();
/** Resolved at the first request, once every mechanism has registered — which may be after this extension was ready. */
private List<SecurityScheme> schemes(SecurityExtension security) {
if (schemes == null) {
schemes = config.mechanisms().isEmpty() ? security.schemes()
: config.mechanisms().stream().flatMap(mechanism -> mechanism.schemes().stream()).toList();
}
return schemes;
}
/** {@code "/"} is the application's own authorization server, at {@code origin}. */
private static List<String> issuers(List<SecurityScheme> schemes, String origin) {
return schemes.stream().map(SecurityScheme::issuer).filter(Objects::nonNull).map(issuer -> issuer.equals("/") ? origin : issuer).toList();
}
}
@@ -1,6 +1,10 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.security.AuthenticationMechanism;
import dev.relism.flash.ext.security.Principal;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityScheme;
import dev.relism.flash.models.Request;
import dev.relism.flash.ext.security.apikey.ApiKey;
import dev.relism.flash.ext.security.apikey.ApiKeyExtension;
import dev.relism.flash.ext.security.apikey.GeneratedApiKey;
@@ -13,6 +17,7 @@ import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
@@ -32,6 +37,27 @@ class McpSecurityTest {
.install(new McpExtension(McpConfig.builder("secure").toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured")
.scopesSupported("openid", "email").build())));
static final OidcExtension oidc = new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret"));
/** Another issuer the application trusts, which the restricted endpoint below must neither accept nor advertise. */
static final AuthenticationMechanism elsewhere = new AuthenticationMechanism() {
@Override public Principal authenticate(Request req) {
return "Other".equals(req.header("Authorization")) ? () -> "other" : null;
}
@Override public List<SecurityScheme> schemes() {
return List.of(SecurityScheme.openIdConnect("other", "https://other.example"));
}
};
/** Only the OIDC provider authenticates this endpoint, however many mechanisms the application has. */
@RegisterExtension
static final FlashTest restricted = FlashTest.of(flash -> flash
.install(new SecurityExtension().mechanism(elsewhere))
.install(oidc)
.install(apiKeys)
.install(new McpExtension(McpConfig.builder("restricted").toolsPackage("dev.relism.flash.ext.mcp.fixtures")
.mechanisms(oidc).requireTokenAudience(false).build())));
/** The same chain with the RFC 8707 check turned off, for an authorization server that cannot mint a resource audience. */
@RegisterExtension
static final FlashTest relaxed = FlashTest.of(flash -> flash
@@ -83,6 +109,28 @@ class McpSecurityTest {
.post("/mcp").expectStatus(200).expectBodyContains("protocolVersion");
}
/** The resource is the application's own origin: a forwarded header naming another host cannot make its tokens good here. */
@Test
void aForwardedHostCannotChooseTheResource() {
app.request().with(provider.bearer("u", Map.of("aud", "https://evil.example/mcp")))
.header("X-Forwarded-Proto", "https").header("X-Forwarded-Host", "evil.example").header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(403);
}
@Test
void anEndpointRestrictedToSomeMechanismsAcceptsAndAdvertisesOnlyThem() {
restricted.get("/.well-known/oauth-protected-resource/mcp").expectStatus(200)
.expectBody("{\"resource\":\"http://127.0.0.1:" + restricted.port() + "/mcp\",\"authorization_servers\":[\"" + provider.issuer() + "\"]}");
Consumer<FlashRequest> key = request -> request.header("Authorization", "Bearer " + KEY.token());
Consumer<FlashRequest> other = request -> request.header("Authorization", "Other");
for (Consumer<FlashRequest> refused : List.of(key, other)) {
restricted.request().with(refused).header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(401);
}
restricted.request().with(provider.bearer("u", Map.of())).header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(200);
}
/** An API key is not audience-bound: the same chain authenticates agents that never saw an authorization server. */
@Test
void anApiKeyIsAcceptedBesideOAuth() {