This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new 69ed0a2560 Adapt authenticated JWT identities for Agent Gateway tools
(#7811)
69ed0a2560 is described below
commit 69ed0a2560b8de191098eaef3c2cda147b008ce1
Author: BobSong <[email protected]>
AuthorDate: Wed Oct 21 10:47:15 2026 +0800
Adapt authenticated JWT identities for Agent Gateway tools (#7811)
* feat(agent-gateway): map authenticated JWT identity and tool grants
* test(agent-gateway): retain default CSRF protection in JWT fixture
* fix(agent-gateway): accept valid JWT audience claims
* docs(agent-gateway): clarify JWT tenant policy
---
.../shenyu-plugin-agent-gateway/README.md | 72 ++++++
.../agent/gateway/security/AgentMcpIdentity.java | 10 +
.../gateway/security/AgentMcpIdentityTest.java | 58 +++++
.../gateway/AgentGatewayPluginConfiguration.java | 51 +++-
.../agent/gateway/JwtAgentMcpSecurityResolver.java | 131 +++++++++++
.../AgentGatewayPluginConfigurationTest.java | 79 +++++++
.../plugin/agent/gateway/JwtAgentMcpHttpTest.java | 262 +++++++++++++++++++++
.../gateway/JwtAgentMcpSecurityResolverTest.java | 178 ++++++++++++++
8 files changed, 831 insertions(+), 10 deletions(-)
diff --git a/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
b/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
index ecc70d8315..490d1f8458 100644
--- a/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
+++ b/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
@@ -53,6 +53,78 @@ The Admin plugin is initialized disabled. Enable
`agentGateway` in Admin only
after the starter is loaded. Disabling the Admin plugin stops new context
creation while leaving the existing AI Proxy and MCP plugins unchanged.
+## Optional authenticated JWT identity bridge
+
+The Agent Gateway starter can adapt an existing Spring Security Resource Server
+authentication. It does **not** authenticate raw JWTs, create a decoder/filter
chain,
+or turn the legacy JWT plugin's forwarded headers into trusted identity.
+The application must include its Resource Server dependencies and configure its
+reactive security chain to verify signatures, permitted algorithms, issuer,
+audience and token lifetime before requests reach Agent Gateway.
+Reuse the application's authentication; do not register a second authentication
+service merely for MCP.
+
+This bridge is disabled by default. Application configuration, not Admin rule
+JSON or client metadata, selects its authority:
+
+```yaml
+shenyu:
+ plugins:
+ agent:
+ gateway:
+ enabled: true
+ security:
+ jwt:
+ enabled: true
+ issuer: https://idp.example.com/issuer
+ audience: shenyu-agent-mcp
+ # Choose exactly one tenant policy:
+ tenant-claim-required: true
+ # Or configure a tenant used only when tenant_id is absent:
+ # tenant-claim-required: false
+ # default-tenant: explicitly-assigned-tenant
+```
+
+Only an authenticated `JwtAuthenticationToken` installed on the exchange
principal
+is accepted. The bridge additionally requires the exact configured `iss` and
+an `aud` string or collection containing the configured audience; its
principal name must
+equal the nonblank `sub` claim. It reads these dedicated, verified claims:
+
+| Claim | Contract |
+| --- | --- |
+| `tenant_id` | Nonblank tenant string. Only an absent claim can use explicit
`default-tenant`; null/invalid values are rejected. Startup requires exactly
one of `tenant-claim-required: true` or `default-tenant`, so missing-tenant
behavior is an explicit deployment policy. |
+| `tool_grants` | Optional array of exact tool names; absence grants nothing.
Generic OAuth scopes/roles are not implicitly MCP permissions. |
+| `tool_denies` | Optional array of exact tool names; deny takes precedence
over grant. |
+
+For example, signed claims `"tool_grants":["orders.lookup","inventory.lookup"]`
+and `"tool_denies":["inventory.lookup"]` grant only `orders.lookup`, still
+intersected with the rule's `allowedTools`. Listing and execution share that
+same effective set. Denied tools are not disclosed by the list and cannot be
+called directly. Remote names retain their `server.tool` prefix.
+
+Claims are copied into an immutable identity. Identity strings are bounded at
+256 characters; each permission collection at 1,024 entries; tool names at 128
+ASCII letters, digits, underscores, dots or hyphens. Wildcards and malformed
+collections are rejected, not partially accepted. Invalid or absent identities
+produce the existing generic HTTP 401 before consuming the MCP body; transport
+preflight checks still precede identity resolution. Missing permission produces
+the existing authorization result, not an anonymous identity.
+
+Exactly one `AgentMcpSecurityResolver` may be registered. Enabling this bridge
+together with another resolver is a startup error, even with `@Primary`;
+disable the bridge when installing a different explicit authority. Optional
+JWT APIs are not required when it is disabled; enabling without them fails
+startup. Legacy custom adapters remain compatible: their two-argument
+`AgentMcpIdentity` has no tenant, not an implicit default. Tenant-aware
governance
+must handle that absence explicitly. This change does not infer OAuth login or
+API-key identity from arbitrary principals.
+
+The tenant is available to a provider through the request-local Reactor
+`AgentMcpExecutionContext.getTenant()`; no identity header is added to remote
+MCP calls. The bridge neither logs credentials/claims nor owns SDK/key
lifecycles.
+Cross-request context/event unification and additional authentication adapters
+remain separate work.
+
## Rule handle
The LLM rule handle uses these fields:
diff --git
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/security/AgentMcpIdentity.java
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/security/AgentMcpIdentity.java
index 4597497d21..af9cf0b7ff 100644
---
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/security/AgentMcpIdentity.java
+++
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/security/AgentMcpIdentity.java
@@ -35,6 +35,16 @@ public final class AgentMcpIdentity {
private final String tenant;
+ /**
+ * Create a legacy unscoped identity without a tenant assignment.
+ *
+ * <p>The absence of a tenant assignment is intentional for adapters that
predate
+ * tenant-aware authorization; callers must treat {@link #getTenant()}
being empty as
+ * unscoped rather than as an anonymous tenant.</p>
+ *
+ * @param subject verified subject
+ * @param toolGrants exact tool names granted by the authority
+ */
public AgentMcpIdentity(final String subject, final Set<String>
toolGrants) {
this(subject, toolGrants, Set.of(), null);
}
diff --git
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/security/AgentMcpIdentityTest.java
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/security/AgentMcpIdentityTest.java
new file mode 100644
index 0000000000..b084788849
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/security/AgentMcpIdentityTest.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.agent.gateway.security;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AgentMcpIdentityTest {
+
+ @Test
+ void legacyAdaptersHaveNoFabricatedTenant() {
+ AgentMcpIdentity identity = new AgentMcpIdentity("legacy",
Set.of("read"));
+ assertTrue(identity.getTenant().isEmpty());
+ assertTrue(identity.getToolDenials().isEmpty());
+ assertEquals(Set.of("read"), identity.getToolGrants());
+ }
+
+ @Test
+ void denyWinsAndInputsAreRequestOwnedCopies() {
+ Set<String> grants = new HashSet<>(Set.of("read", "write"));
+ Set<String> denies = new HashSet<>(Set.of("write", "unknown"));
+ AgentMcpIdentity identity = new AgentMcpIdentity("tenant", "subject",
grants, denies);
+ grants.add("admin");
+ denies.clear();
+ assertEquals(Set.of("read"), identity.getToolGrants());
+ assertEquals(Set.of("write", "unknown"), identity.getToolDenials());
+ assertEquals("tenant", identity.getTenant().orElseThrow());
+ assertThrows(UnsupportedOperationException.class, () ->
identity.getToolGrants().clear());
+ }
+
+ @Test
+ void tenantBoundConstructorRequiresAnExplicitNonBlankTenant() {
+ assertThrows(NullPointerException.class, () -> new
AgentMcpIdentity(null, "subject", Set.of(), Set.of()));
+ assertThrows(IllegalArgumentException.class, () -> new
AgentMcpIdentity(" ", "subject", Set.of(), Set.of()));
+ assertThrows(IllegalArgumentException.class, () -> new
AgentMcpIdentity("tenant", " ", Set.of(), Set.of()));
+ }
+}
diff --git
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfiguration.java
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfiguration.java
index b9467ef277..4bd2cec580 100644
---
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfiguration.java
+++
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfiguration.java
@@ -35,6 +35,7 @@ import
org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.BeanFactory;
import
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.core.env.Environment;
import
org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -56,6 +57,8 @@ import reactor.core.publisher.Mono;
@ConditionalOnProperty(value = "shenyu.plugins.agent.gateway.enabled",
havingValue = "true", matchIfMissing = false)
public class AgentGatewayPluginConfiguration {
+ private static final String JWT_SECURITY_RESOLVER_BEAN_NAME =
"agentGatewayJwtSecurityResolver";
+
/**
* Create a plugin with an optional managed remote catalog.
* @param providers local providers
@@ -63,35 +66,40 @@ public class AgentGatewayPluginConfiguration {
* @param catalogs optional, unambiguous managed remote catalog
* @param tracers optional tracing bridge
* @param environment deployment-controlled tracing opt-in
+ * @param beanFactory application bean factory used to verify the
conditional JWT resolver
* @return the plugin
*/
@Bean
public ShenyuPlugin agentGatewayPlugin(final
ObjectProvider<AgentToolProvider> providers, final
ObjectProvider<AgentMcpSecurityResolver> resolvers,
final
ObjectProvider<AgentMcpRemoteCatalog> catalogs, final
ObjectProvider<AgentMcpCallTracer> tracers,
- final Environment environment) {
+ final Environment environment, final
BeanFactory beanFactory) {
AgentMcpCallTracer tracer = tracers.getIfAvailable();
if
(environment.getProperty("shenyu.plugins.agent.gateway.tracing.enabled",
Boolean.class, false) && java.util.Objects.isNull(tracer)) {
throw new IllegalStateException("Enabled MCP tracing requires an
application tracing bridge");
}
+ List<AgentMcpSecurityResolver> authorities =
resolvers.stream().toList();
+ if (authorities.size() > 1) {
+ throw new IllegalStateException("MCP requires exactly one
authoritative security resolver");
+ }
if
(environment.getProperty("shenyu.plugins.agent.gateway.security.key-auth.enabled",
Boolean.class, false)
- && resolvers.stream().noneMatch(resolver -> resolver
instanceof KeyAuthMcpSecurityResolver)) {
+ && authorities.stream().noneMatch(resolver -> resolver
instanceof KeyAuthMcpSecurityResolver)) {
throw new IllegalStateException("Enabled key-auth MCP
authorization requires the key-auth plugin dependency");
}
if
(environment.getProperty("shenyu.plugins.agent.gateway.security.principal.enabled",
Boolean.class, false)
- && resolvers.stream().noneMatch(resolver ->
"org.apache.shenyu.springboot.starter.plugin.agent.gateway.PrincipalMcpSecurityResolver".equals(resolver.getClass().getName())))
{
+ && authorities.stream().noneMatch(resolver ->
"org.apache.shenyu.springboot.starter.plugin.agent.gateway.PrincipalMcpSecurityResolver".equals(resolver.getClass().getName())))
{
throw new IllegalStateException("Enabled MCP principal
authorization requires an explicit source and its optional dependencies");
}
- return createPlugin(providers, resolvers, catalogs.getIfAvailable(),
tracer);
+ if
(environment.getProperty("shenyu.plugins.agent.gateway.security.jwt.enabled",
Boolean.class, false)
+ && !beanFactory.containsBean(JWT_SECURITY_RESOLVER_BEAN_NAME))
{
+ throw new IllegalStateException("Enabled MCP JWT identity requires
the optional JWT adapter dependencies");
+ }
+ return createPlugin(providers, authorities, catalogs.getIfAvailable(),
tracer);
}
- private ShenyuPlugin createPlugin(final ObjectProvider<AgentToolProvider>
providers, final ObjectProvider<AgentMcpSecurityResolver> resolvers,
+ private ShenyuPlugin createPlugin(final ObjectProvider<AgentToolProvider>
providers, final List<AgentMcpSecurityResolver> resolvers,
final AgentMcpRemoteCatalog catalog,
final AgentMcpCallTracer tracer) {
AgentToolRegistry registry = new
AgentToolRegistry(providers.orderedStream().toList());
- List<AgentMcpSecurityResolver> candidates =
resolvers.stream().toList();
- if (candidates.size() > 1) {
- throw new IllegalStateException("MCP requires exactly one
authoritative security resolver");
- }
- AgentMcpSecurityResolver resolver = candidates.isEmpty() ? exchange ->
Mono.empty() : candidates.get(0);
+ AgentMcpSecurityResolver resolver = resolvers.isEmpty() ? exchange ->
Mono.empty() : resolvers.get(0);
return new AgentGatewayPlugin(new AgentMcpHttpHandler(new
AgentMcpDispatcher(registry, "shenyu-agent-gateway",
AgentGatewayConstants.MCP_SERVER_VERSION, catalog), resolver, tracer));
}
@@ -223,6 +231,29 @@ public class AgentGatewayPluginConfiguration {
}
}
+ /**
+ * Optional bridge for an application-owned, authenticated Resource Server
JWT principal.
+ */
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnClass(name =
{"org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken",
+ "org.springframework.security.oauth2.jwt.Jwt"})
+ @ConditionalOnProperty(value =
"shenyu.plugins.agent.gateway.security.jwt.enabled", havingValue = "true")
+ static class JwtIdentityConfiguration {
+
+ /**
+ * Adapt verified claims without creating a decoder or authentication
filter chain.
+ * @param environment deployment-owned identity policy
+ * @return the sole identity authority
+ */
+ @Bean(name = JWT_SECURITY_RESOLVER_BEAN_NAME)
+ AgentMcpSecurityResolver agentGatewayJwtSecurityResolver(final
Environment environment) {
+ String prefix = "shenyu.plugins.agent.gateway.security.jwt.";
+ return new
JwtAgentMcpSecurityResolver(environment.getRequiredProperty(prefix + "issuer"),
+ environment.getRequiredProperty(prefix + "audience"),
environment.getProperty(prefix + "default-tenant"),
+ environment.getProperty(prefix + "tenant-claim-required",
Boolean.class, false));
+ }
+ }
+
/**
* Optional API bridge; SDK, exporters and their lifecycle belong to the
application.
*/
diff --git
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpSecurityResolver.java
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpSecurityResolver.java
new file mode 100644
index 0000000000..447e63ef9c
--- /dev/null
+++
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/main/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpSecurityResolver.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.springboot.starter.plugin.agent.gateway;
+
+import org.apache.shenyu.plugin.agent.gateway.security.AgentMcpIdentity;
+import
org.apache.shenyu.plugin.agent.gateway.security.AgentMcpSecurityResolver;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import
org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Maps only an application-authenticated JWT principal, never a raw token or
header.
+ * A missing default tenant means that an authenticated token must carry
{@code tenant_id}.
+ * The application's Resource Server owns signature, algorithm and temporal
validation.
+ */
+public final class JwtAgentMcpSecurityResolver implements
AgentMcpSecurityResolver {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(JwtAgentMcpSecurityResolver.class);
+
+ private final String issuer;
+
+ private final String audience;
+
+ private final String defaultTenant;
+
+ private final boolean tenantClaimRequired;
+
+ public JwtAgentMcpSecurityResolver(final String issuer, final String
audience, final String defaultTenant) {
+ this(issuer, audience, defaultTenant, Objects.isNull(defaultTenant));
+ }
+
+ /**
+ * Create a resolver with an explicit policy for tokens that omit the
tenant claim.
+ * @param issuer required JWT issuer
+ * @param audience required JWT audience
+ * @param defaultTenant explicit fallback tenant, or null when the claim
is required
+ * @param tenantClaimRequired whether tokens must contain {@code tenant_id}
+ */
+ public JwtAgentMcpSecurityResolver(final String issuer, final String
audience, final String defaultTenant, final boolean tenantClaimRequired) {
+ this.issuer = text(issuer);
+ this.audience = text(audience);
+ this.defaultTenant = Objects.isNull(defaultTenant) ? null :
text(defaultTenant);
+ if (Objects.isNull(this.defaultTenant) != tenantClaimRequired) {
+ throw new IllegalArgumentException("Configure exactly one of
default tenant or required tenant claim");
+ }
+ this.tenantClaimRequired = tenantClaimRequired;
+ }
+
+ @Override
+ public Mono<AgentMcpIdentity> resolve(final ServerWebExchange exchange) {
+ return exchange.getPrincipal().ofType(JwtAuthenticationToken.class)
+ .filter(JwtAuthenticationToken::isAuthenticated)
+ .flatMap(authentication -> {
+ try {
+ Map<String, Object> claims =
authentication.getTokenAttributes();
+ if (!issuer.equals(claims.get("iss")) ||
!names(claims.get("aud"), false).contains(audience)) {
+ return Mono.empty();
+ }
+ String subject = text(claims.get("sub"));
+ if (!subject.equals(authentication.getName())) {
+ return Mono.empty();
+ }
+ String tenant;
+ if (claims.containsKey("tenant_id")) {
+ tenant = text(claims.get("tenant_id"));
+ } else if (Objects.nonNull(defaultTenant)) {
+ tenant = defaultTenant;
+ } else if (tenantClaimRequired) {
+ return Mono.empty();
+ } else {
+ throw new IllegalStateException("Tenant identity
policy is not configured");
+ }
+ return Mono.just(new AgentMcpIdentity(tenant, subject,
+ names(claims.getOrDefault("tool_grants",
Set.of()), true), names(claims.getOrDefault("tool_denies", Set.of()), true)));
+ } catch (IllegalArgumentException error) {
+ // Reject malformed claims and diagnose without
logging JWT or claim values.
+ LOG.debug("Rejected authenticated JWT with invalid
Agent Gateway identity claims");
+ return Mono.empty();
+ }
+ });
+ }
+
+ private static String text(final Object value) {
+ if (!(value instanceof String string) || string.isBlank() ||
string.length() > 256
+ || !string.equals(string.trim()) ||
string.chars().anyMatch(Character::isISOControl)) {
+ throw new IllegalArgumentException("Invalid identity claim or
policy");
+ }
+ return string;
+ }
+
+ private static Set<String> names(final Object value, final boolean tools) {
+ if (!tools && value instanceof String string) {
+ return Set.of(text(string));
+ }
+ if (!(value instanceof Collection<?> values) || values.size() > 1024
|| !tools && values.isEmpty()) {
+ throw new IllegalArgumentException("Invalid permission or audience
claim");
+ }
+ Set<String> result = new HashSet<>();
+ for (Object item : values) {
+ String name = text(item);
+ if (tools && (name.length() > 128 ||
!name.matches("[a-zA-Z0-9_.-]+"))) {
+ throw new IllegalArgumentException("Invalid tool permission");
+ }
+ result.add(name);
+ }
+ return Set.copyOf(result);
+ }
+}
diff --git
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfigurationTest.java
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfigurationTest.java
index 1385178896..3a9c086fc3 100644
---
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfigurationTest.java
+++
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/AgentGatewayPluginConfigurationTest.java
@@ -23,6 +23,7 @@ import
org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpHttpHandler;
import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpCallTracer;
import io.opentelemetry.api.OpenTelemetry;
import org.springframework.boot.test.context.FilteredClassLoader;
+import org.springframework.beans.factory.config.BeanPostProcessor;
import org.apache.shenyu.plugin.agent.gateway.security.AgentMcpIdentity;
import
org.apache.shenyu.plugin.agent.gateway.security.AgentMcpSecurityResolver;
import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolProvider;
@@ -45,6 +46,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
@@ -168,6 +170,14 @@ class AgentGatewayPluginConfigurationTest {
.run(context -> assertNotNull(context.getStartupFailure()));
}
+ @Test
+ void primaryMustNotHideConflictingIdentityAuthorities() {
+
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true")
+ .withBean("firstSecurity", AgentMcpSecurityResolver.class, ()
-> next -> Mono.empty(), definition -> definition.setPrimary(true))
+ .withBean("secondSecurity", AgentMcpSecurityResolver.class, ()
-> next -> Mono.empty())
+ .run(context -> assertNotNull(context.getStartupFailure()));
+ }
+
@Test
void shouldAssembleOptInCatalogAndWireTrustedPluginSync() {
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true",
"shenyu.plugins.agent.gateway.aggregation.enabled=true",
@@ -194,6 +204,75 @@ class AgentGatewayPluginConfigurationTest {
}).run(context -> assertNotNull(context.getStartupFailure()));
}
+ @Test
+ void jwtBridgeIsOptInAndRequiresItsExactAuthorityPolicy() {
+
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true").run(context
->
+
assertFalse(context.containsBean("agentGatewayJwtSecurityResolver")));
+
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true",
"shenyu.plugins.agent.gateway.security.jwt.enabled=true")
+ .run(context -> assertNotNull(context.getStartupFailure()));
+ jwtContext().run(context -> {
+ assertTrue(context.getBean(AgentMcpSecurityResolver.class)
instanceof JwtAgentMcpSecurityResolver);
+
assertTrue(context.containsBean("agentGatewayJwtSecurityResolver"));
+ });
+ }
+
+ @Test
+ void jwtBridgeCannotCompeteWithACustomPrimaryAuthority() {
+ jwtContext().withBean("custom", AgentMcpSecurityResolver.class, () ->
next -> Mono.empty(), definition -> definition.setPrimary(true))
+ .run(context -> assertNotNull(context.getStartupFailure()));
+ }
+
+ @Test
+ void jwtBridgeRecognizesItsResolverWhenWrappedInJdkProxy() {
+ jwtContext().withBean("jwtResolverProxy", BeanPostProcessor.class, ()
-> new BeanPostProcessor() {
+ @Override
+ public Object postProcessAfterInitialization(final Object bean,
final String beanName) {
+ if (!"agentGatewayJwtSecurityResolver".equals(beanName)) {
+ return bean;
+ }
+ return
java.lang.reflect.Proxy.newProxyInstance(bean.getClass().getClassLoader(),
+ new Class<?>[] {AgentMcpSecurityResolver.class},
(proxy, method, arguments) -> method.invoke(bean, arguments));
+ }
+ }).run(context -> {
+ assertNull(context.getStartupFailure());
+
assertTrue(java.lang.reflect.Proxy.isProxyClass(context.getBean(AgentMcpSecurityResolver.class).getClass()));
+ });
+ }
+
+ @Test
+ void
optionalJwtClassesAreNotRequiredWhenOffButCannotSilentlyDisappearWhenEnabled() {
+ contextRunner.withClassLoader(new
FilteredClassLoader("org.springframework.security.oauth2"))
+
.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true")
+ .run(context ->
assertNotNull(context.getBean(AgentGatewayPlugin.class)));
+ jwtContext().withClassLoader(new
FilteredClassLoader("org.springframework.security.oauth2"))
+ .run(context -> assertNotNull(context.getStartupFailure()));
+ }
+
+ @Test
+ void blankDefaultTenantIsAConfigurationFailure() {
+
jwtContext().withPropertyValues("shenyu.plugins.agent.gateway.security.jwt.default-tenant=")
+ .run(context -> assertNotNull(context.getStartupFailure()));
+ }
+
+ @Test
+ void jwtBridgeRequiresExactlyOneExplicitTenantPolicyAtStartup() {
+
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true",
"shenyu.plugins.agent.gateway.security.jwt.enabled=true",
+
"shenyu.plugins.agent.gateway.security.jwt.issuer=https://issuer.example",
"shenyu.plugins.agent.gateway.security.jwt.audience=mcp")
+ .run(context -> assertNotNull(context.getStartupFailure()));
+
jwtContext().withPropertyValues("shenyu.plugins.agent.gateway.security.jwt.default-tenant=tenant-a",
+
"shenyu.plugins.agent.gateway.security.jwt.tenant-claim-required=true")
+ .run(context -> assertNotNull(context.getStartupFailure()));
+
jwtContext().withPropertyValues("shenyu.plugins.agent.gateway.security.jwt.default-tenant=tenant-a",
+
"shenyu.plugins.agent.gateway.security.jwt.tenant-claim-required=false")
+ .run(context -> assertNull(context.getStartupFailure()));
+ }
+
+ private ApplicationContextRunner jwtContext() {
+ return
contextRunner.withPropertyValues("shenyu.plugins.agent.gateway.enabled=true",
"shenyu.plugins.agent.gateway.security.jwt.enabled=true",
+
"shenyu.plugins.agent.gateway.security.jwt.issuer=https://issuer.example",
"shenyu.plugins.agent.gateway.security.jwt.audience=mcp",
+
"shenyu.plugins.agent.gateway.security.jwt.tenant-claim-required=true");
+ }
+
private MockServerWebExchange exchange() {
return
MockServerWebExchange.from(MockServerHttpRequest.post("/agent/mcp")
.header("Content-Type", "application/json").header("Accept",
"application/json, text/event-stream")
diff --git
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpHttpTest.java
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpHttpTest.java
new file mode 100644
index 0000000000..810472af46
--- /dev/null
+++
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpHttpTest.java
@@ -0,0 +1,262 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.springboot.starter.plugin.agent.gateway;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.nimbusds.jose.jwk.source.ImmutableSecret;
+import org.apache.shenyu.common.dto.AgentGatewayMcpConfig;
+import org.apache.shenyu.plugin.agent.gateway.AgentTrafficContext;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpDispatcher;
+import
org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpExecutionContext;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpHttpHandler;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolProvider;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolRegistry;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.JwtValidators;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
+import org.springframework.security.web.server.WebFilterChainProxy;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.web.reactive.function.client.WebClient;
+import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.netty.DisposableServer;
+import reactor.netty.http.server.HttpServer;
+import reactor.netty.resources.LoopResources;
+
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Real loopback HTTP and signature verification, not a preinstalled identity
fixture.
+ */
+class JwtAgentMcpHttpTest {
+
+ private static final String ISSUER = "https://issuer.example";
+
+ private static final byte[] KEY =
"jwt-mcp-test-only-key-never-use-in-production".getBytes(StandardCharsets.UTF_8);
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void missingBearerRetainsCsrfProtectionAndRequiresAuthentication(final
String mode) {
+ try (Fixture fixture = new Fixture(mode, "explicit-default")) {
+ fixture.request(null, "tools/call").header("X-Tenant-Id",
"forged").header("X-Agent-Subject", "agent")
+ .exchange().expectStatus().isForbidden();
+
fixture.client.get().uri("/").exchange().expectStatus().isUnauthorized();
+ assertEquals(0, fixture.calls.get());
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void signedJwtControlsListingAndCallingWithDenyPrecedence(final String
mode) {
+ try (Fixture fixture = new Fixture(mode, null)) {
+ String allowed = token(Map.of("tenant_id", "tenant-a",
"tool_grants", List.of("read")), KEY, ISSUER);
+ String denied = token(Map.of("tenant_id", "tenant-a",
"tool_grants", List.of("read"), "tool_denies", List.of("read")), KEY, ISSUER);
+ fixture.request(allowed,
"tools/list").exchange().expectStatus().isOk().expectBody(String.class)
+ .value(body ->
assertTrue(body.contains("\"name\":\"read\"")));
+ fixture.request(denied,
"tools/list").exchange().expectStatus().isOk().expectBody(String.class)
+ .value(body -> assertTrue(body.contains("\"tools\":[]")));
+ fixture.request(denied,
"tools/call").exchange().expectStatus().isForbidden();
+ assertEquals(0, fixture.calls.get());
+ fixture.request(allowed,
"tools/call").exchange().expectStatus().isOk().expectBody(String.class)
+ .value(body ->
assertTrue(body.contains("\"tenant\":\"tenant-a\"")));
+ assertEquals(1, fixture.calls.get());
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void signedJwtAcceptsAStandardsCompliantSingleStringAudience(final String
mode) {
+ try (Fixture fixture = new Fixture(mode, null)) {
+ String allowed = token(Map.of("aud", "mcp", "tenant_id",
"tenant-a", "tool_grants", List.of("read")), KEY, ISSUER);
+ fixture.request(allowed,
"tools/call").exchange().expectStatus().isOk().expectBody(String.class)
+ .value(body ->
assertTrue(body.contains("\"tenant\":\"tenant-a\"")));
+ assertEquals(1, fixture.calls.get());
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void forgedExpiredWrongIssuerAndWrongAudienceNeverReachTool(final String
mode) {
+ try (Fixture fixture = new Fixture(mode, "explicit-default")) {
+ fixture.request(token(Map.of("tool_grants", List.of("read")),
"another-untrusted-test-key-at-least-32-bytes".getBytes(StandardCharsets.UTF_8),
ISSUER),
+ "tools/call").exchange().expectStatus().isUnauthorized();
+ fixture.request(token(Map.of("tool_grants", List.of("read")), KEY,
"https://untrusted.example"), "tools/call")
+ .exchange().expectStatus().isUnauthorized();
+ fixture.request(token(Map.of("aud", List.of("other"),
"tool_grants", List.of("read")), KEY, ISSUER), "tools/call")
+ .exchange().expectStatus().isUnauthorized();
+ fixture.request(token(Map.of("exp",
Instant.now().minusSeconds(120), "tool_grants", List.of("read")), KEY, ISSUER),
"tools/call")
+ .exchange().expectStatus().isUnauthorized();
+ fixture.request(token(Map.of("tenant_id", "", "tool_grants",
List.of("read")), KEY, ISSUER), "tools/call")
+ .exchange().expectStatus().isUnauthorized();
+ assertEquals(0, fixture.calls.get());
+ fixture.request(token(Map.of("tool_grants", List.of("read")), KEY,
ISSUER), "tools/call")
+ .exchange().expectStatus().isOk().expectBody(String.class)
+ .value(body ->
assertTrue(body.contains("\"tenant\":\"explicit-default\"")));
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void ruleIntersectionAndMissingTenantCannotBeBypassed(final String mode) {
+ try (Fixture fixture = new Fixture(mode, null)) {
+ fixture.request(token(Map.of("tool_grants", List.of("read")), KEY,
ISSUER), "tools/call")
+ .exchange().expectStatus().isUnauthorized();
+ assertEquals(0, fixture.calls.get());
+ }
+ try (Fixture fixture = new Fixture(mode, null, "[]")) {
+ String allowed = token(Map.of("tenant_id", "tenant-a",
"tool_grants", List.of("read")), KEY, ISSUER);
+ fixture.request(allowed,
"tools/list").exchange().expectStatus().isOk().expectBody(String.class)
+ .value(body -> assertTrue(body.contains("\"tools\":[]")));
+ fixture.request(allowed,
"tools/call").exchange().expectStatus().isForbidden();
+ assertEquals(0, fixture.calls.get());
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void concurrentSameRpcIdsKeepTenantAndSubjectBoundToTheirRequest(final
String mode) {
+ try (Fixture fixture = new Fixture(mode, null)) {
+ WebClient client = WebClient.create(fixture.url);
+ Flux.range(0, 32).flatMap(index -> {
+ String tenant = "tenant-" + index;
+ String jwt = token(Map.of("sub", "agent-" + index,
"tenant_id", tenant, "tool_grants", List.of("read")), KEY, ISSUER);
+ return client.post().uri("/").headers(headers -> {
+ headers.setBearerAuth(jwt);
+ headers.set("Content-Type", "application/json");
+ headers.set("Accept", "application/json,
text/event-stream");
+ headers.set("MCP-Protocol-Version", "2026-07-28");
+ headers.set("Mcp-Method", "tools/call");
+ headers.set("Mcp-Name", "read");
+
}).bodyValue(body("tools/call")).retrieve().bodyToMono(String.class)
+ .doOnNext(result -> {
+ assertTrue(result.contains("\"tenant\":\"" +
tenant + "\""));
+ assertTrue(result.contains("\"subject\":\"agent-"
+ index + "\""));
+ });
+ }, 32).collectList().block(Duration.ofSeconds(20));
+ assertEquals(32, fixture.calls.get());
+ }
+ }
+
+ private String token(final Map<String, Object> claims, final byte[] key,
final String issuer) {
+ Instant now = Instant.now();
+ JwtClaimsSet.Builder builder =
JwtClaimsSet.builder().issuer(issuer).subject("agent").audience(List.of("mcp"))
+
.issuedAt(now.minusSeconds(180)).expiresAt(now.plusSeconds(300));
+ claims.forEach(builder::claim);
+ return new NimbusJwtEncoder(new ImmutableSecret<>(key)).encode(
+
JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(),
builder.build())).getTokenValue();
+ }
+
+ private static String body(final String method) {
+ return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"" + method +
"\",\"params\":{"
+ + ("tools/call".equals(method) ? "\"name\":\"read\"," : "")
+ +
"\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientCapabilities\":{}}}}";
+ }
+
+ private static final class Fixture implements AutoCloseable {
+
+ private final AtomicInteger calls = new AtomicInteger();
+
+ private final LoopResources loops =
LoopResources.create("jwt-mcp-http", 2, true);
+
+ private final DisposableServer server;
+
+ private final WebTestClient client;
+
+ private final String url;
+
+ Fixture(final String mode, final String defaultTenant) {
+ this(mode, defaultTenant, "[\"read\"]");
+ }
+
+ Fixture(final String mode, final String defaultTenant, final String
allowedTools) {
+ AgentToolProvider tool = mock(AgentToolProvider.class);
+ when(tool.getName()).thenReturn("read");
+ when(tool.getDescription()).thenReturn("Read a fixture");
+
when(tool.getInputSchema()).thenReturn(JsonParser.parseString("{\"type\":\"object\"}").getAsJsonObject());
+ when(tool.getRequiredClientCapabilities()).thenReturn(new
JsonObject());
+ when(tool.invoke(any())).thenAnswer(ignored ->
Mono.deferContextual(context -> Mono.delay(Duration.ofMillis(5)).map(tick -> {
+ calls.incrementAndGet();
+ JsonObject result = new JsonObject();
+ result.addProperty("tenant",
context.get(AgentMcpExecutionContext.class).getTenant().orElseThrow());
+ result.addProperty("subject",
context.get(AgentMcpExecutionContext.class).getSubject());
+ return result;
+ })));
+ AgentMcpHttpHandler handler = new AgentMcpHttpHandler(new
AgentMcpDispatcher(new AgentToolRegistry(List.of(tool)), "fixture", "1"),
+ new JwtAgentMcpSecurityResolver(ISSUER, "mcp",
defaultTenant));
+ AgentGatewayMcpConfig config =
AgentGatewayMcpConfig.parse(JsonParser.parseString(
+ "{\"allowedTools\":" + allowedTools +
",\"responseMode\":\"" + mode + "\"}"), true);
+ NimbusReactiveJwtDecoder decoder =
NimbusReactiveJwtDecoder.withSecretKey(new SecretKeySpec(KEY, "HmacSHA256"))
+ .macAlgorithm(MacAlgorithm.HS256).build();
+
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(ISSUER));
+ // Resource Server handles bearer requests; retain default CSRF
protection for requests without a bearer token.
+ var security = ServerHttpSecurity.http()
+ .authorizeExchange(exchanges ->
exchanges.anyExchange().authenticated())
+ .oauth2ResourceServer(resource -> resource.jwt(jwt ->
jwt.jwtDecoder(decoder))).build();
+ var httpHandler = WebHttpHandlerBuilder.webHandler(exchange ->
handler.handle(exchange, config,
+ new AgentTrafficContext("fixture-request", "mcp",
"selector", "rule"), 1))
+ .filter(new WebFilterChainProxy(security)).build();
+ server =
HttpServer.create().host("127.0.0.1").port(0).runOn(loops).handle(new
ReactorHttpHandlerAdapter(httpHandler)).bindNow();
+ url = "http://127.0.0.1:" + server.port();
+ client =
WebTestClient.bindToServer().baseUrl(url).responseTimeout(Duration.ofSeconds(10)).build();
+ }
+
+ WebTestClient.RequestHeadersSpec<?> request(final String token, final
String method) {
+ return client.post().uri("/").headers(headers -> {
+ if (Objects.nonNull(token)) {
+ headers.setBearerAuth(token);
+ }
+ headers.set("Content-Type", "application/json");
+ headers.set("Accept", "application/json, text/event-stream");
+ headers.set("MCP-Protocol-Version", "2026-07-28");
+ headers.set("Mcp-Method", method);
+ if ("tools/call".equals(method)) {
+ headers.set("Mcp-Name", "read");
+ }
+ }).bodyValue(body(method));
+ }
+
+ @Override
+ public void close() {
+ server.disposeNow();
+ loops.disposeLater().block(Duration.ofSeconds(10));
+ }
+ }
+}
diff --git
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpSecurityResolverTest.java
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpSecurityResolverTest.java
new file mode 100644
index 0000000000..53cabbc917
--- /dev/null
+++
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-agent-gateway/src/test/java/org/apache/shenyu/springboot/starter/plugin/agent/gateway/JwtAgentMcpSecurityResolverTest.java
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.springboot.starter.plugin.agent.gateway;
+
+import org.apache.shenyu.plugin.agent.gateway.security.AgentMcpIdentity;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.Arguments;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.security.oauth2.jwt.Jwt;
+import
org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import java.security.Principal;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class JwtAgentMcpSecurityResolverTest {
+
+ private final JwtAgentMcpSecurityResolver resolver = new
JwtAgentMcpSecurityResolver("https://issuer.example", "mcp", null);
+
+ @Test
+ void mapsVerifiedClaimsWithDenyPrecedenceAndImmutableSnapshots() {
+ List<String> grants = new ArrayList<>(List.of("read",
"orders.lookup"));
+ Map<String, Object> claims = claims();
+ claims.put("tool_grants", grants);
+ claims.put("tool_denies", List.of("read"));
+ AgentMcpIdentity identity = resolve(resolver, authenticated(claims));
+ grants.add("admin");
+ assertEquals("tenant-a", identity.getTenant().orElseThrow());
+ assertEquals("agent", identity.getSubject());
+ assertEquals(Set.of("orders.lookup"), identity.getToolGrants());
+ assertEquals(Set.of("read"), identity.getToolDenials());
+ assertThrows(UnsupportedOperationException.class, () ->
identity.getToolGrants().add("admin"));
+ assertThrows(UnsupportedOperationException.class, () ->
identity.getToolDenials().clear());
+ }
+
+ @Test
+ void unverifiedJwtAndOrdinaryPrincipalAndForgedHeadersAreNotIdentity() {
+ assertNull(resolve(resolver, new
JwtAuthenticationToken(jwt(claims()))));
+ assertNull(resolve(resolver, (Principal) () -> "agent"));
+ assertNull(resolve(resolver, null));
+ }
+
+ @Test
+ void rejectsConflictingPrincipalAliasRatherThanChoosingIt() {
+ assertNull(resolve(resolver, new JwtAuthenticationToken(jwt(claims()),
List.of(), "other-agent")));
+ }
+
+ @Test
+ void explicitDefaultTenantAppliesOnlyToAuthenticatedMissingClaim() {
+ Map<String, Object> claims = claims();
+ claims.remove("tenant_id");
+ JwtAgentMcpSecurityResolver configured = new
JwtAgentMcpSecurityResolver("https://issuer.example", "mcp", "server-tenant");
+ assertNull(resolve(resolver, authenticated(claims)));
+ assertEquals("server-tenant", resolve(configured,
authenticated(claims)).getTenant().orElseThrow());
+ assertNull(resolve(configured, null));
+ assertNull(resolve(configured, new
JwtAuthenticationToken(jwt(claims))));
+ claims.put("tenant_id", "");
+ assertNull(resolve(configured, authenticated(claims)));
+ }
+
+ @Test
+ void acceptsSingleStringAudienceClaim() {
+ Map<String, Object> claims = claims();
+ claims.put("aud", "mcp");
+ assertEquals("tenant-a", resolve(resolver,
authenticated(claims)).getTenant().orElseThrow());
+ }
+
+ @Test
+ void missingGrantsNeverBecomeAllToolsOrScopes() {
+ Map<String, Object> claims = claims();
+ claims.remove("tool_grants");
+ claims.put("scope", "read admin");
+ assertTrue(resolve(resolver,
authenticated(claims)).getToolGrants().isEmpty());
+ }
+
+ @ParameterizedTest
+ @MethodSource("invalidClaims")
+ void malformedOrWrongAuthorityClaimsAreRejected(final String key, final
Object value) {
+ Map<String, Object> claims = claims();
+ claims.put(key, value);
+ assertNull(resolve(resolver, authenticated(claims)));
+ }
+
+ private static Stream<Arguments> invalidClaims() {
+ return Stream.of(
+ Arguments.of("iss", "https://other.example"),
Arguments.of("iss", List.of("https://issuer.example")),
+ Arguments.of("aud", List.of("other")), Arguments.of("aud",
""), Arguments.of("aud", List.of()), Arguments.of("aud", List.of("mcp", 42)),
+ Arguments.of("tenant_id", null), Arguments.of("tenant_id",
42), Arguments.of("tenant_id", " tenant-a"),
+ Arguments.of("tenant_id", "a\nb"), Arguments.of("tenant_id",
"x".repeat(257)),
+ Arguments.of("sub", ""), Arguments.of("sub", "agent "),
Arguments.of("sub", 42),
+ Arguments.of("tool_grants", "read"),
Arguments.of("tool_grants", null), Arguments.of("tool_grants", List.of("*")),
+ Arguments.of("tool_grants", List.of("read", 42)),
Arguments.of("tool_grants", List.of("a".repeat(129))),
+ Arguments.of("tool_grants",
java.util.Collections.nCopies(1025, "read")),
+ Arguments.of("tool_denies", "read"),
Arguments.of("tool_denies", null), Arguments.of("tool_denies",
List.of("read/child")));
+ }
+
+ @Test
+ void rejectsInvalidDeploymentPolicyAtConstruction() {
+ assertThrows(IllegalArgumentException.class, () -> new
JwtAgentMcpSecurityResolver("", "mcp", null));
+ assertThrows(IllegalArgumentException.class, () -> new
JwtAgentMcpSecurityResolver("issuer", " ", null));
+ assertThrows(IllegalArgumentException.class, () -> new
JwtAgentMcpSecurityResolver("issuer", "mcp", ""));
+ assertThrows(IllegalArgumentException.class, () -> new
JwtAgentMcpSecurityResolver("issuer", "mcp", null, false));
+ assertThrows(IllegalArgumentException.class, () -> new
JwtAgentMcpSecurityResolver("issuer", "mcp", "tenant-a", true));
+ }
+
+ @Test
+ void concurrentSameSubjectInDifferentTenantsDoesNotShareIdentity() {
+ Map<String, Object> first = claims();
+ Map<String, Object> second = claims();
+ second.put("tenant_id", "tenant-b");
+ second.put("tool_denies", List.of("read"));
+ List<AgentMcpIdentity> identities = reactor.core.publisher.Flux.merge(
+ resolveAsync(resolver,
authenticated(first)).subscribeOn(Schedulers.parallel()),
+ resolveAsync(resolver,
authenticated(second)).subscribeOn(Schedulers.parallel())).collectList().block();
+ assertEquals(2, identities.size());
+ assertEquals(Set.of("tenant-a", "tenant-b"),
identities.stream().map(identity ->
identity.getTenant().orElseThrow()).collect(java.util.stream.Collectors.toSet()));
+ assertEquals(1, identities.stream().filter(identity ->
identity.getToolGrants().contains("read")).count());
+ }
+
+ private AgentMcpIdentity resolve(final JwtAgentMcpSecurityResolver
adapter, final Principal principal) {
+ return resolveAsync(adapter, principal).block();
+ }
+
+ private Mono<AgentMcpIdentity> resolveAsync(final
JwtAgentMcpSecurityResolver adapter, final Principal principal) {
+ MockServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.post("/agent/mcp")
+ .header("Authorization", "Bearer
not-a-verified-token").header("X-Tenant-Id", "forged")
+ .header("X-Agent-Subject", "forged").header("tool_grants",
"admin"));
+ return
adapter.resolve(exchange.mutate().principal(Mono.justOrEmpty(principal)).build());
+ }
+
+ private JwtAuthenticationToken authenticated(final Map<String, Object>
claims) {
+ return new JwtAuthenticationToken(jwt(claims), List.of());
+ }
+
+ private Jwt jwt(final Map<String, Object> claims) {
+ return new Jwt("fixture-not-a-raw-credential", Instant.now(),
Instant.now().plusSeconds(300), Map.of("alg", "RS256"), claims);
+ }
+
+ private Map<String, Object> claims() {
+ Map<String, Object> claims = new HashMap<>();
+ claims.put("iss", "https://issuer.example");
+ claims.put("aud", List.of("mcp"));
+ claims.put("sub", "agent");
+ claims.put("tenant_id", "tenant-a");
+ claims.put("tool_grants", List.of("read"));
+ return claims;
+ }
+}