Feature/ext validation/request validation #15

Merged
Relism merged 10 commits from feature/ext-validation/request-validation into master 2026-09-09 14:21:17 +00:00
2 changed files with 121 additions and 171 deletions
Showing only changes of commit c02459dd7c - Show all commits
@@ -2,16 +2,10 @@ package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.extension.FlashApp; import dev.relism.flash.testing.FlashResponse;
import org.junit.jupiter.api.AfterEach; import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -22,34 +16,15 @@ class McpExtensionIntegrationTest {
private static final ObjectMapper MAPPER = new ObjectMapper(); private static final ObjectMapper MAPPER = new ObjectMapper();
private FlashApp app; // Every test here is a stateless JSON-RPC call against the same server, so one boot for
private String mcpUrl; // the class rather than one per test.
private HttpClient client; @RegisterExtension
static FlashTest mcp = FlashTest.of(app -> app.install(new McpExtension(
@BeforeEach McpConfig.builder("test-server")
void setUp() throws Exception {
int port;
try (ServerSocket s = new ServerSocket(0)) {
port = s.getLocalPort();
}
mcpUrl = "http://127.0.0.1:" + port + "/mcp";
client = HttpClient.newHttpClient();
McpConfig config = McpConfig.builder("test-server")
.version("9.9.9") .version("9.9.9")
.toolsPackage("dev.relism.flash.ext.mcp.fixtures") .toolsPackage("dev.relism.flash.ext.mcp.fixtures")
.security(McpSecurity.NONE) .security(McpSecurity.NONE)
.build(); .build())));
app = FlashApp.create(port);
app.install(new McpExtension(config));
app.start();
}
@AfterEach
void tearDown() {
if (app != null) app.stop();
}
@Test @Test
void initialize_returnsProtocolVersionCapabilitiesAndServerInfo() throws Exception { void initialize_returnsProtocolVersionCapabilitiesAndServerInfo() throws Exception {
@@ -104,17 +79,13 @@ class McpExtensionIntegrationTest {
} }
@Test @Test
void notification_returns202WithEmptyBody() throws Exception { void notification_returns202WithEmptyBody() {
String body = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}"; post("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}").expectStatus(202);
HttpResponse<String> resp = post(body);
assertEquals(202, resp.statusCode());
} }
@Test @Test
void malformedJson_returns400ParseError() throws Exception { void malformedJson_returns400ParseError() throws Exception {
HttpResponse<String> resp = post("not json"); JsonNode json = MAPPER.readTree(post("not json").expectStatus(400).body());
assertEquals(400, resp.statusCode());
JsonNode json = MAPPER.readTree(resp.body());
assertEquals(-32700, json.get("error").get("code").asInt()); assertEquals(-32700, json.get("error").get("code").asInt());
} }
@@ -128,16 +99,10 @@ class McpExtensionIntegrationTest {
private JsonNode call(int id, String method, String paramsJson) throws Exception { private JsonNode call(int id, String method, String paramsJson) throws Exception {
String body = "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + paramsJson + "}"; String body = "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + paramsJson + "}";
HttpResponse<String> resp = post(body); return MAPPER.readTree(post(body).expectStatus(200).body());
assertEquals(200, resp.statusCode());
return MAPPER.readTree(resp.body());
} }
private HttpResponse<String> post(String body) throws Exception { private FlashResponse post(String body) {
HttpRequest req = HttpRequest.newBuilder(URI.create(mcpUrl)) return mcp.request().json(body).post("/mcp");
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
return client.send(req, HttpResponse.BodyHandlers.ofString());
} }
} }
@@ -3,16 +3,15 @@ package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.oidc.OidcConfig; import dev.relism.flash.ext.oidc.OidcConfig;
import dev.relism.flash.ext.oidc.OidcExtension; import dev.relism.flash.ext.oidc.OidcExtension;
import dev.relism.flash.extension.FlashApp; import dev.relism.flash.extension.FlashApp;
import org.junit.jupiter.api.AfterEach; import dev.relism.flash.extension.FlashApplication;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.testing.FlashRequest;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -20,175 +19,161 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-oidc} * Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-oidc}
* installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256 * installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
* tokens — plus the fail-fast/degrade behavior when oidc is absent. * tokens — plus the fail-fast/degrade behavior when oidc is absent.
*
* <p>Four server configurations differ only in how MCP security is declared, so each gets its
* own {@link FlashTest} and they share one provider.
*/ */
class McpExtensionSecurityTest { class McpExtensionSecurityTest {
private static final String TOOLS_PACKAGE = "dev.relism.flash.ext.mcp.fixtures"; private static final String TOOLS_PACKAGE = "dev.relism.flash.ext.mcp.fixtures";
private static final String EXPLICIT_RESOURCE_ID = "https://mcp.example.com/mcp";
private FlashApp app; private static final FakeOidcProvider provider = newProvider();
private FakeOidcProvider provider;
@AfterEach /** MCP asked for AUTO security with no oidc installed — should degrade to public. */
void tearDown() { @RegisterExtension
if (app != null) app.stop(); static FlashTest degraded = FlashTest.of(app -> app.install(new McpExtension(
if (provider != null) provider.close(); McpConfig.builder("auto-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.AUTO)
.build())));
/** REQUIRED with oidc, resource identifier derived from the request. */
@RegisterExtension
static FlashTest secured = FlashTest.of(securedApp(null, null));
/** REQUIRED with oidc and an explicitly declared resource identifier. */
@RegisterExtension
static FlashTest securedWithResourceId = FlashTest.of(securedApp(EXPLICIT_RESOURCE_ID, null));
/** REQUIRED with oidc and advertised scopes. */
@RegisterExtension
static FlashTest securedWithScopes =
FlashTest.of(securedApp(null, new String[] {"openid", "profile", "email"}));
@AfterAll
static void closeProvider() {
provider.close();
} }
// ── No oidc installed ────────────────────────────────────────────────────
@Test @Test
void required_withoutOidc_throwsAtBoot() throws Exception { void required_withoutOidc_throwsAtBoot() {
int port = freePort(); // Asserting that boot fails, so this one builds its app directly rather than through
app = FlashApp.create(port); // the harness; port(0) still removes the old free-port dance.
FlashApp app = FlashApp.create(FlashConfiguration.builder()
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
app.install(new McpExtension(McpConfig.builder("secure-server") app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE) .toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED) .security(McpSecurity.REQUIRED)
.build())); .build()));
try {
assertThrows(IllegalStateException.class, () -> app.start()); assertThrows(IllegalStateException.class, app::start);
} finally {
app.stop().join();
}
} }
@Test @Test
void auto_withoutOidc_degradesToPublic() throws Exception { void auto_withoutOidc_degradesToPublic() {
int port = freePort(); post(degraded, initializeBody(), null).expectStatus(200);
app = FlashApp.create(port);
app.install(new McpExtension(McpConfig.builder("auto-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.AUTO)
.build()));
app.start();
HttpResponse<String> resp = post(port, initializeBody(), null);
assertEquals(200, resp.statusCode());
} }
@Test // ── REQUIRED with oidc ───────────────────────────────────────────────────
void required_withOidc_rejectsMissingToken() throws Exception {
int port = bootSecuredApp(null);
HttpResponse<String> resp = post(port, initializeBody(), null); @Test
assertEquals(401, resp.statusCode()); void required_withOidc_rejectsMissingToken() {
post(secured, initializeBody(), null).expectStatus(401);
} }
@Test @Test
void required_withOidc_rejectsWrongAudience() throws Exception { void required_withOidc_rejectsWrongAudience() throws Exception {
int port = bootSecuredApp("https://mcp.example.com/mcp");
String token = provider.signToken("user-1", "https://someone-else.example.com/resource"); String token = provider.signToken("user-1", "https://someone-else.example.com/resource");
HttpResponse<String> resp = post(port, initializeBody(), token); post(securedWithResourceId, initializeBody(), token).expectStatus(403);
assertEquals(403, resp.statusCode());
} }
@Test @Test
void required_withOidc_acceptsValidAudience() throws Exception { void required_withOidc_acceptsValidAudience() throws Exception {
String resourceId = "https://mcp.example.com/mcp"; String token = provider.signToken("user-1", EXPLICIT_RESOURCE_ID);
int port = bootSecuredApp(resourceId);
String token = provider.signToken("user-1", resourceId);
HttpResponse<String> resp = post(port, initializeBody(), token); post(securedWithResourceId, initializeBody(), token)
assertEquals(200, resp.statusCode()); .expectStatus(200)
assertTrue(resp.body().contains("\"protocolVersion\"")); .expectBodyContains("\"protocolVersion\"");
} }
@Test @Test
void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception { void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception {
int port = bootSecuredApp(null); String derivedResourceId = "http://127.0.0.1:" + secured.port() + "/mcp";
String derivedResourceId = "http://127.0.0.1:" + port + "/mcp";
String matching = provider.signToken("user-1", derivedResourceId); post(secured, initializeBody(), provider.signToken("user-1", derivedResourceId))
assertEquals(200, post(port, initializeBody(), matching).statusCode()); .expectStatus(200);
post(secured, initializeBody(), provider.signToken("user-1", "https://someone-else.example.com/resource"))
String mismatched = provider.signToken("user-1", "https://someone-else.example.com/resource"); .expectStatus(403);
assertEquals(403, post(port, initializeBody(), mismatched).statusCode());
} }
@Test @Test
void required_withOidc_missingToken_challengeIncludesResourceMetadata() throws Exception { void required_withOidc_missingToken_challengeIncludesResourceMetadata() {
int port = bootSecuredApp(null); FlashResponse response = post(secured, initializeBody(), null).expectStatus(401);
HttpResponse<String> resp = post(port, initializeBody(), null); String challenge = response.header("WWW-Authenticate");
assertEquals(401, resp.statusCode()); assertTrue(challenge != null && challenge.contains("resource_metadata=\"http://127.0.0.1:"
String challenge = resp.headers().firstValue("WWW-Authenticate").orElse(""); + secured.port() + "/.well-known/oauth-protected-resource/mcp\""),
assertTrue(challenge.contains(
"resource_metadata=\"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp\""),
"WWW-Authenticate: " + challenge); "WWW-Authenticate: " + challenge);
} }
// ── Protected resource metadata ──────────────────────────────────────────
@Test @Test
void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() throws Exception { void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() {
int port = bootSecuredApp(null); FlashResponse response = secured.get("/.well-known/oauth-protected-resource/mcp")
.expectStatus(200)
.expectBodyContains("\"resource\":\"http://127.0.0.1:" + secured.port() + "/mcp\"")
.expectBodyContains("\"authorization_servers\":[\"" + provider.issuer() + "\"]");
HttpResponse<String> resp = HttpClient.newHttpClient().send( assertTrue(!response.body().contains("scopes_supported"),
HttpRequest.newBuilder(URI.create( "scopes_supported must be omitted when unset: " + response.body());
"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp")).GET().build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(200, resp.statusCode());
assertTrue(resp.body().contains("\"resource\":\"http://127.0.0.1:" + port + "/mcp\""), resp.body());
assertTrue(resp.body().contains("\"authorization_servers\":[\"" + provider.issuer() + "\"]"), resp.body());
assertTrue(!resp.body().contains("scopes_supported"), "scopes_supported must be omitted when unset: " + resp.body());
} }
@Test @Test
void scopesSupported_published_inProtectedResourceMetadata() throws Exception { void scopesSupported_published_inProtectedResourceMetadata() {
provider = new FakeOidcProvider(); securedWithScopes.get("/.well-known/oauth-protected-resource/mcp")
int port = freePort(); .expectStatus(200)
.expectBodyContains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]");
app = FlashApp.create(port);
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED)
.scopesSupported("openid", "profile", "email")
.build()));
app.start();
HttpResponse<String> resp = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(
"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp")).GET().build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(200, resp.statusCode());
assertTrue(resp.body().contains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]"), resp.body());
} }
// ── Helpers ────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────
private int bootSecuredApp(String resourceIdentifier) throws Exception { private static FlashApplication securedApp(String resourceIdentifier, String[] scopesSupported) {
provider = new FakeOidcProvider(); return app -> {
int port = freePort(); app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
OidcConfig oidcConfig = OidcConfig.builder( McpConfig.Builder mcp = McpConfig.builder("secure-server")
provider.issuer(), "mcp-client", "secret", "/auth/callback")
.build();
var mcpBuilder = McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE) .toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED); .security(McpSecurity.REQUIRED);
if (resourceIdentifier != null) mcpBuilder.resourceIdentifier(resourceIdentifier); if (resourceIdentifier != null) mcp.resourceIdentifier(resourceIdentifier);
if (scopesSupported != null) mcp.scopesSupported(scopesSupported);
app.install(new McpExtension(mcp.build()));
};
}
app = FlashApp.create(port); private static FlashResponse post(FlashTest server, String body, String bearerToken) {
app.install(new OidcExtension(oidcConfig)); FlashRequest request = server.request().header("Accept", "application/json").json(body);
app.install(new McpExtension(mcpBuilder.build())); if (bearerToken != null) request.header("Authorization", "Bearer " + bearerToken);
app.start(); return request.post("/mcp");
return port;
} }
private static String initializeBody() { private static String initializeBody() {
return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"; return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}";
} }
private static int freePort() throws Exception { private static FakeOidcProvider newProvider() {
try (ServerSocket s = new ServerSocket(0)) { try {
return s.getLocalPort(); return new FakeOidcProvider();
} catch (Exception failure) {
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
} }
} }
private static HttpResponse<String> post(int port, String body, String bearerToken) throws Exception {
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/mcp"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body));
if (bearerToken != null) req.header("Authorization", "Bearer " + bearerToken);
return HttpClient.newHttpClient().send(req.build(), HttpResponse.BodyHandlers.ofString());
}
} }