MCP OAuth discovery fix + Supabase consent page (#6608)

This commit is contained in:
Anthony Stirling
2026-06-11 18:30:49 +01:00
committed by GitHub
parent 9e5fe2f4ca
commit 1d598d5caa
18 changed files with 1146 additions and 270 deletions
@@ -367,6 +367,15 @@ public class ApplicationProperties {
*/
private String resourceId = "";
/**
* Additional JWT audiences accepted at the MCP endpoint, on top of {@link #resourceId}.
* Empty (default) keeps strict RFC 8707 binding. Some IdPs cannot mint
* resource-specific audiences - e.g. Supabase's OAuth server always issues {@code
* aud=authenticated} - so operators list the audience their IdP actually emits here
* (env: {@code MCP_AUTH_ACCEPTEDAUDIENCES}, comma-separated).
*/
private List<String> acceptedAudiences = new ArrayList<>();
/**
* JWT claim whose value is matched against a provisioned Stirling username. Defaults to
* {@code sub}; set to {@code email} or {@code preferred_username} to match how your IdP
@@ -390,6 +390,9 @@ mcp:
jwksUri: "" # JWKS URI. Blank -> derived from issuer's /.well-known/openid-configuration.
resourceId: "" # RFC 8707 resource identifier of THIS MCP server (e.g. http://localhost:8080/mcp).
# Required: tokens must list this id in `aud` or the request is rejected.
acceptedAudiences: [] # Extra `aud` values accepted on top of resourceId. Empty = strict RFC 8707.
# For IdPs that cannot mint resource audiences (Supabase OAuth server always
# issues aud=authenticated) list that audience here, e.g. ['authenticated'].
usernameClaim: sub # JWT claim matched against a Stirling username (e.g. 'sub', 'email', 'preferred_username')
requireExistingAccount: true # Reject tokens whose subject has no enabled Stirling account (recommended)
engineCapabilityRefreshMinutes: 5 # How often to refresh the AI capabilities manifest from the engine
@@ -1,6 +1,9 @@
package stirling.software.proprietary.mcp.security;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
@@ -8,20 +11,35 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
/**
* RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id in its
* {@code aud} claim. Fails closed when the resource id is unset.
* RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id (or one
* of the explicitly accepted additional audiences) in its {@code aud} claim. The additional list
* exists for IdPs that cannot mint resource-specific audiences - e.g. Supabase's OAuth server
* always issues {@code aud=authenticated}. Fails closed when nothing is configured.
*/
public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
private final String expectedResourceId;
private final Set<String> acceptedAudiences;
public McpAudienceValidator(String expectedResourceId) {
this.expectedResourceId = expectedResourceId == null ? "" : expectedResourceId;
this(expectedResourceId, List.of());
}
public McpAudienceValidator(String expectedResourceId, Collection<String> additionalAudiences) {
Set<String> accepted = new LinkedHashSet<>();
if (expectedResourceId != null && !expectedResourceId.isBlank()) {
accepted.add(expectedResourceId);
}
if (additionalAudiences != null) {
additionalAudiences.stream()
.filter(a -> a != null && !a.isBlank())
.forEach(accepted::add);
}
this.acceptedAudiences = accepted;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt token) {
if (expectedResourceId.isBlank()) {
if (acceptedAudiences.isEmpty()) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error(
"invalid_token",
@@ -30,12 +48,13 @@ public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
null));
}
List<String> aud = token.getAudience();
if (aud == null || !aud.contains(expectedResourceId)) {
if (aud == null || aud.stream().noneMatch(acceptedAudiences::contains)) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error(
"invalid_token",
"Token audience does not include this server's resource id ("
+ expectedResourceId
"Token audience does not include this server's resource id or an"
+ " accepted audience ("
+ String.join(", ", acceptedAudiences)
+ ").",
null));
}
@@ -157,7 +157,11 @@ public class McpSecurityConfig {
throws Exception {
String metadataPath = "/.well-known/oauth-protected-resource";
applyCors(http);
http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath)
// RFC 9728 section 3.1: clients derive the metadata URL by inserting the well-known
// segment before the resource path, so /mcp is discovered at {metadataPath}/mcp. Claim
// the subpaths too; otherwise they fall through to another filter chain whose default
// Spring Security metadata filter serves a document without authorization_servers.
http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath, metadataPath + "/**")
// CSRF intentionally disabled: /mcp is a stateless JSON-RPC resource server
// authenticated by OAuth2 Bearer JWTs (Authorization header). No cookies, no
// session, no form submissions; CSRF requires browser-attached ambient credentials
@@ -168,7 +172,8 @@ public class McpSecurityConfig {
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(
a ->
a.requestMatchers(HttpMethod.GET, metadataPath)
a.requestMatchers(
HttpMethod.GET, metadataPath, metadataPath + "/**")
.permitAll()
.anyRequest()
.authenticated())
@@ -187,7 +192,11 @@ public class McpSecurityConfig {
.oauth2ResourceServer(
oauth2 ->
oauth2.authenticationEntryPoint(
new McpAuthenticationEntryPoint(metadataPath))
// Advertise the path-inserted form; RFC 9728 makes
// it the canonical location for a resource with a
// path component.
new McpAuthenticationEntryPoint(
metadataPath + BASE_PATH))
// RFC 9728 protected-resource metadata for OAuth discovery.
.protectedResourceMetadata(
prm ->
@@ -236,7 +245,9 @@ public class McpSecurityConfig {
JwtValidators.createDefaultWithIssuer(auth.getIssuerUri());
OAuth2TokenValidator<Jwt> combined =
new DelegatingOAuth2TokenValidator<>(
defaultValidators, new McpAudienceValidator(auth.getResourceId()));
defaultValidators,
new McpAudienceValidator(
auth.getResourceId(), auth.getAcceptedAudiences()));
decoder.setJwtValidator(combined);
return decoder;
}
@@ -53,6 +53,30 @@ class McpAudienceValidatorTest {
assertThat(result.hasErrors()).isTrue();
}
@Test
void acceptedAudience_isAccepted_alongsideResourceId() {
// Supabase-style IdP: every token carries aud=authenticated, never the resource id.
McpAudienceValidator relaxed = new McpAudienceValidator(RESOURCE, List.of("authenticated"));
assertThat(relaxed.validate(tokenWithAudience(List.of("authenticated"))).hasErrors())
.isFalse();
assertThat(relaxed.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isFalse();
assertThat(relaxed.validate(tokenWithAudience(List.of("something-else"))).hasErrors())
.isTrue();
}
@Test
void blankAcceptedAudienceEntries_areIgnored() {
McpAudienceValidator relaxed = new McpAudienceValidator(RESOURCE, List.of("", " "));
assertThat(relaxed.validate(tokenWithAudience(List.of(""))).hasErrors()).isTrue();
assertThat(relaxed.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isFalse();
}
@Test
void blankResourceIdWithOnlyBlankAccepted_failsClosed() {
McpAudienceValidator blank = new McpAudienceValidator("", List.of(" "));
assertThat(blank.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isTrue();
}
private static Jwt tokenWithAudience(List<String> audience) {
return new Jwt(
"header.payload.signature",
@@ -116,7 +116,8 @@ class McpOAuthIntegrationTest {
assertThat(response.statusCode()).isEqualTo(401);
String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse("");
assertThat(wwwAuth).contains("resource_metadata=");
assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource");
// The advertised URL must be the RFC 9728 path-inserted form for the /mcp resource.
assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource/mcp");
}
@Test
@@ -247,6 +248,24 @@ class McpOAuthIntegrationTest {
assertThat(response.body()).contains("mcp.tools.read");
}
@Test
void pathInsertedMetadataEndpoint_servesCustomizedMetadata() throws Exception {
// RFC 9728 path-inserted form for the /mcp resource. Must be served by the MCP chain
// with authorization_servers populated; a default/uncustomized document here makes MCP
// clients fall back to treating this server as its own authorization server.
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(base() + "/.well-known/oauth-protected-resource/mcp"))
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).contains(RESOURCE_ID);
assertThat(response.body()).contains("authorization_servers");
assertThat(response.body()).contains(ISSUER);
assertThat(response.body()).contains("mcp.tools.read");
}
private HttpResponse<String> postMcp(String token, String body) throws Exception {
HttpRequest.Builder builder =
HttpRequest.newBuilder()