mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
MCP OAuth discovery fix + Supabase consent page (#6608)
This commit is contained in:
@@ -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
|
||||
|
||||
+27
-8
@@ -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));
|
||||
}
|
||||
|
||||
+15
-4
@@ -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;
|
||||
}
|
||||
|
||||
+24
@@ -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",
|
||||
|
||||
+20
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user