mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
feat(mcp): type on-behalf-of identity by channel/trust (#459)
The identity forwarded to opt-in MCP servers was a one-dimensional string (ChatOrigin.requesterId): a MateClaw username for web logins, but a webchat visitorId for visitors and an IM sender id for IM — indistinguishable to the REST backend. The signed-token mode (d204b702) made this worse: an RS256 signature over an unauthenticated visitorId reads as "MateClaw authenticated this user" to any backend that trusts the signature. Introduce an identity-typing dimension at McpIdentityForwardService: - classify() branches on ChatOrigin: authenticated (web login, sub=immutable userId), anonymous (webchat visitor, trust=anonymous), external (IM sender, trust=external), or none (cron/system → nothing injected, fail-closed). - mint() adds `trust` and `channel_type` claims; plaintext value is prefixed `trust:subject` so backends can tell the kinds apart without a JWT. The immutable userId reaches resolve() without coupling it to the user store: JwtAuthFilter stamps user.id into auth.setDetails() (both JWT and PAT paths), and ChatController.memoryOrigin carries it on a new ChatOrigin.requesterUserId field (only-add, per the record's evolution rule). Resolves the webchat semantic mismatch raised in #459 and the "sub should be an immutable user id" follow-up. 82 tests green (4 identity classes covered with claim assertions + full ChatOrigin/MCP regression). (cherry picked from commit b5d2cfbf98b39848d7139c743a0b81fea71e8ffe)
This commit is contained in:
parent
fcd682e4b4
commit
a1221ac02d
@ -66,7 +66,16 @@ public record ChatOrigin(
|
|||||||
* can still mint absolute download links. Null for IM/cron origins, which
|
* can still mint absolute download links. Null for IM/cron origins, which
|
||||||
* have no request host; those rely on {@code mateclaw.server.public-base-url}.
|
* have no request host; those rely on {@code mateclaw.server.public-base-url}.
|
||||||
*/
|
*/
|
||||||
@Nullable String baseUrl
|
@Nullable String baseUrl,
|
||||||
|
/**
|
||||||
|
* Immutable numeric id of the MateClaw user behind this request, when the
|
||||||
|
* requester is an <em>authenticated</em> account (JWT/PAT login via the
|
||||||
|
* web console). Null for non-account origins — webchat visitors, IM
|
||||||
|
* senders, cron — which carry no MateClaw user row. On-behalf-of identity
|
||||||
|
* forwarding uses this to tell "MateClaw authenticated this user" apart
|
||||||
|
* from "this is an external/anonymous identifier" (RFC: identity typing).
|
||||||
|
*/
|
||||||
|
@Nullable Long requesterUserId
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
|
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
|
||||||
@ -74,7 +83,7 @@ public record ChatOrigin(
|
|||||||
|
|
||||||
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
||||||
public static final ChatOrigin EMPTY =
|
public static final ChatOrigin EMPTY =
|
||||||
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null);
|
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null, null);
|
||||||
|
|
||||||
// ---------------- Factories per entry point ----------------
|
// ---------------- Factories per entry point ----------------
|
||||||
|
|
||||||
@ -90,9 +99,25 @@ public record ChatOrigin(
|
|||||||
@Nullable Long workspaceId,
|
@Nullable Long workspaceId,
|
||||||
@Nullable String workspaceBasePath,
|
@Nullable String workspaceBasePath,
|
||||||
@Nullable String baseUrl) {
|
@Nullable String baseUrl) {
|
||||||
|
return web(conversationId, requesterId, workspaceId, workspaceBasePath, baseUrl, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web-console origin that also carries the authenticated user's immutable
|
||||||
|
* numeric id. Use this overload from the authenticated web entry point so
|
||||||
|
* on-behalf-of identity forwarding can assert "MateClaw authenticated this
|
||||||
|
* user" rather than an external/anonymous identifier.
|
||||||
|
*/
|
||||||
|
public static ChatOrigin web(@Nullable String conversationId,
|
||||||
|
@Nullable String requesterId,
|
||||||
|
@Nullable Long workspaceId,
|
||||||
|
@Nullable String workspaceBasePath,
|
||||||
|
@Nullable String baseUrl,
|
||||||
|
@Nullable Long requesterUserId) {
|
||||||
return new ChatOrigin(null, conversationId,
|
return new ChatOrigin(null, conversationId,
|
||||||
requesterId != null ? requesterId : "",
|
requesterId != null ? requesterId : "",
|
||||||
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl);
|
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl,
|
||||||
|
requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ChatOrigin cron(@Nullable String conversationId,
|
public static ChatOrigin cron(@Nullable String conversationId,
|
||||||
@ -101,7 +126,7 @@ public record ChatOrigin(
|
|||||||
@Nullable Long channelId,
|
@Nullable Long channelId,
|
||||||
@Nullable ChannelTarget target) {
|
@Nullable ChannelTarget target) {
|
||||||
return new ChatOrigin(null, conversationId, "system",
|
return new ChatOrigin(null, conversationId, "system",
|
||||||
workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null);
|
workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Wither-style updates ----------------
|
// ---------------- Wither-style updates ----------------
|
||||||
@ -109,27 +134,27 @@ public record ChatOrigin(
|
|||||||
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||||
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
senderName, channelType, chatId, baseUrl);
|
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||||
@Nullable String newWorkspaceBasePath) {
|
@Nullable String newWorkspaceBasePath) {
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
senderName, channelType, chatId, baseUrl);
|
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||||
return new ChatOrigin(agentId, newConversationId, requesterId,
|
return new ChatOrigin(agentId, newConversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
senderName, channelType, chatId, baseUrl);
|
senderName, channelType, chatId, baseUrl, requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */
|
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */
|
||||||
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
|
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
senderName, channelType, chatId, newBaseUrl);
|
senderName, channelType, chatId, newBaseUrl, requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -143,7 +168,7 @@ public record ChatOrigin(
|
|||||||
@Nullable String newChatId) {
|
@Nullable String newChatId) {
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
|
||||||
newSenderName, newChannelType, newChatId, baseUrl);
|
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Spring AI ToolContext interop ----------------
|
// ---------------- Spring AI ToolContext interop ----------------
|
||||||
|
|||||||
@ -44,7 +44,8 @@ public class ChannelChatOriginFactory {
|
|||||||
? message.getChannelType()
|
? message.getChannelType()
|
||||||
: channel.getChannelType(),
|
: channel.getChannelType(),
|
||||||
/* chatId */ message.getChatId(),
|
/* chatId */ message.getChatId(),
|
||||||
/* baseUrl */ null); // IM origins have no request host; rely on public-base-url config
|
/* baseUrl */ null, // IM origins have no request host; rely on public-base-url config
|
||||||
|
/* requesterUserId */ null); // IM senders are external platform ids, not MateClaw accounts
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -556,7 +556,7 @@ public class ChatController {
|
|||||||
// tools that need a workspace path read it from the agent (origin
|
// tools that need a workspace path read it from the agent (origin
|
||||||
// is enriched with workspaceBasePath in StateGraph buildInitialState).
|
// is enriched with workspaceBasePath in StateGraph buildInitialState).
|
||||||
vip.mate.agent.context.ChatOrigin webOrigin =
|
vip.mate.agent.context.ChatOrigin webOrigin =
|
||||||
memoryOrigin(conversationId, username, workspaceId, request.getEndUserId())
|
memoryOrigin(conversationId, username, requesterUserIdOf(auth), workspaceId, request.getEndUserId())
|
||||||
.withBaseUrl(requestBaseUrl);
|
.withBaseUrl(requestBaseUrl);
|
||||||
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
|
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
|
||||||
.doOnNext(delta -> {
|
.doOnNext(delta -> {
|
||||||
@ -1057,7 +1057,7 @@ public class ChatController {
|
|||||||
// Carry the web origin so per-owner memory recall (read) and the
|
// Carry the web origin so per-owner memory recall (read) and the
|
||||||
// post-conversation memory write below agree on the same owner key.
|
// post-conversation memory write below agree on the same owner key.
|
||||||
vip.mate.agent.context.ChatOrigin webOrigin =
|
vip.mate.agent.context.ChatOrigin webOrigin =
|
||||||
memoryOrigin(request.getConversationId(), username, workspaceId, request.getEndUserId());
|
memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId, request.getEndUserId());
|
||||||
AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin);
|
AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin);
|
||||||
String response = result.content();
|
String response = result.content();
|
||||||
conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed",
|
conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed",
|
||||||
@ -1170,17 +1170,33 @@ public class ChatController {
|
|||||||
* MateClaw user ({@code user:<username>}).
|
* MateClaw user ({@code user:<username>}).
|
||||||
*/
|
*/
|
||||||
private vip.mate.agent.context.ChatOrigin memoryOrigin(String conversationId, String username,
|
private vip.mate.agent.context.ChatOrigin memoryOrigin(String conversationId, String username,
|
||||||
Long workspaceId, String endUserId) {
|
Long requesterUserId, Long workspaceId,
|
||||||
|
String endUserId) {
|
||||||
// Resolve the public base URL here, on the request thread, so it can ride
|
// Resolve the public base URL here, on the request thread, so it can ride
|
||||||
// the origin into async tool execution where no request is bound. Tools
|
// the origin into async tool execution where no request is bound. Tools
|
||||||
// then mint absolute download links without operator config.
|
// then mint absolute download links without operator config.
|
||||||
String baseUrl = resolveRequestBaseUrl();
|
String baseUrl = resolveRequestBaseUrl();
|
||||||
if (endUserId != null && !endUserId.isBlank()) {
|
if (endUserId != null && !endUserId.isBlank()) {
|
||||||
|
// Third-party single-account integration: the requester is an external
|
||||||
|
// end-user id, not a MateClaw account — no requesterUserId to assert.
|
||||||
return vip.mate.agent.context.ChatOrigin
|
return vip.mate.agent.context.ChatOrigin
|
||||||
.web(conversationId, endUserId.trim(), workspaceId, null, baseUrl)
|
.web(conversationId, endUserId.trim(), workspaceId, null, baseUrl)
|
||||||
.withSender(null, "api", null);
|
.withSender(null, "api", null);
|
||||||
}
|
}
|
||||||
return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null, baseUrl);
|
// Authenticated web user: carry the immutable id so on-behalf-of identity
|
||||||
|
// forwarding can assert "MateClaw authenticated this user" (not an anon id).
|
||||||
|
return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null, baseUrl, requesterUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the authenticated user's immutable numeric id from the
|
||||||
|
* {@link Authentication} details (stamped by {@code JwtAuthFilter} for both
|
||||||
|
* the JWT and PAT paths). Null when not authenticated or details absent.
|
||||||
|
*/
|
||||||
|
private Long requesterUserIdOf(org.springframework.security.core.Authentication auth) {
|
||||||
|
if (auth == null) return null;
|
||||||
|
Object details = auth.getDetails();
|
||||||
|
return details instanceof Long id ? id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -79,6 +79,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
|||||||
user.getUsername(), null,
|
user.getUsername(), null,
|
||||||
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase()))
|
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase()))
|
||||||
);
|
);
|
||||||
|
auth.setDetails(user.getId()); // immutable user id for on-behalf-of forwarding
|
||||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||||
patService.recordUse(pat); // debounced inside the service
|
patService.recordUse(pat); // debounced inside the service
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
@ -99,6 +100,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
|||||||
username, null,
|
username, null,
|
||||||
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase()))
|
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase()))
|
||||||
);
|
);
|
||||||
|
auth.setDetails(user.getId()); // immutable user id for on-behalf-of forwarding
|
||||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||||
|
|
||||||
// 滑动窗口续期:Token 接近过期时自动签发新 Token
|
// 滑动窗口续期:Token 接近过期时自动签发新 Token
|
||||||
|
|||||||
@ -205,7 +205,7 @@ public class SkillConsolidationService {
|
|||||||
|
|
||||||
private ToolContext toolContext(String sourceConversationId) {
|
private ToolContext toolContext(String sourceConversationId) {
|
||||||
ChatOrigin origin = new ChatOrigin(null, sourceConversationId, "", null, null,
|
ChatOrigin origin = new ChatOrigin(null, sourceConversationId, "", null, null,
|
||||||
null, null, false, null, null, null, null);
|
null, null, false, null, null, null, null, null);
|
||||||
return new ToolContext(Map.of(ChatOrigin.CTX_KEY, origin));
|
return new ToolContext(Map.of(ChatOrigin.CTX_KEY, origin));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -214,7 +214,7 @@ public class SkillReflectionService {
|
|||||||
*/
|
*/
|
||||||
private ToolContext buildToolContext(Long agentId, String conversationId) {
|
private ToolContext buildToolContext(Long agentId, String conversationId) {
|
||||||
ChatOrigin origin = new ChatOrigin(agentId, conversationId, "", null, null,
|
ChatOrigin origin = new ChatOrigin(agentId, conversationId, "", null, null,
|
||||||
null, null, false, null, null, null, null);
|
null, null, false, null, null, null, null, null);
|
||||||
return new ToolContext(Map.of(ChatOrigin.CTX_KEY, origin));
|
return new ToolContext(Map.of(ChatOrigin.CTX_KEY, origin));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import io.jsonwebtoken.Jwts;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.model.ToolContext;
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||||
|
|
||||||
import java.security.KeyFactory;
|
import java.security.KeyFactory;
|
||||||
@ -20,19 +21,31 @@ import java.util.UUID;
|
|||||||
* Resolves what identity to inject into an opt-in MCP server's tool call, and
|
* Resolves what identity to inject into an opt-in MCP server's tool call, and
|
||||||
* (in token mode) mints the signed assertion.
|
* (in token mode) mints the signed assertion.
|
||||||
*
|
*
|
||||||
* <p>Two modes, per {@link McpIdentityForwardProperties}:
|
* <p><b>Identity typing.</b> Not every requester is a MateClaw-authenticated
|
||||||
|
* account. The resolved identity is typed so the REST backend can tell
|
||||||
|
* "MateClaw authenticated this user" apart from "this is an external/anonymous
|
||||||
|
* identifier" (see RFC: on-behalf-of identity typing):
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li><b>plaintext</b> — returns {@code (__mateclaw_user__, username)}.</li>
|
* <li><b>authenticated</b> — web-console login (JWT/PAT). {@code sub} = the
|
||||||
* <li><b>token</b> — returns {@code (__mateclaw_token__, <RS256 JWT>)} with
|
* user's immutable numeric id (carried on {@link ChatOrigin#requesterUserId()}).
|
||||||
* {@code sub}=username, {@code aud}=server audience, short {@code exp}. The
|
* The backend may authorize on-behalf-of freely.</li>
|
||||||
* REST backend verifies the signature with the matching public key, so it
|
* <li><b>anonymous</b> — webchat visitor / third-party {@code endUserId}. No
|
||||||
* need not trust the MCP service or the transport.</li>
|
* MateClaw account backs it; {@code sub} = the visitor id. The backend must
|
||||||
|
* treat this as unauthenticated and decide for itself whether/how to serve.</li>
|
||||||
|
* <li><b>external</b> — IM sender (feishu/wecom/…). {@code sub} = the platform
|
||||||
|
* sender id; same caveat as anonymous.</li>
|
||||||
|
* <li><b>none</b> — cron / system / unattributed. Nothing is injected (fail-closed):
|
||||||
|
* we never assert identity on behalf of a non-user.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>The {@code sub} carries the MateClaw user identifier
|
* <p>Two transport modes carry the typed identity, per {@link McpIdentityForwardProperties}:
|
||||||
* ({@code ChatOrigin.requesterId}). If your backend keys authorization on an
|
* <ul>
|
||||||
* immutable numeric id, resolve username→id before minting (left as a refinement
|
* <li><b>plaintext</b> — injects {@code <trust>:<subject>} under {@link McpIdentityForwardProperties#USER_ARG}.</li>
|
||||||
* so this layer stays decoupled from the user store).
|
* <li><b>token</b> — injects an RS256 JWT under {@link McpIdentityForwardProperties#TOKEN_ARG}
|
||||||
|
* with {@code sub}, {@code trust}, {@code channel_type}, {@code aud}, short {@code exp}.
|
||||||
|
* The REST backend verifies the signature with the matching public key, so it
|
||||||
|
* need not trust the MCP service or the transport.</li>
|
||||||
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>Fail-closed: when token mode is enabled but the signing key is missing or
|
* <p>Fail-closed: when token mode is enabled but the signing key is missing or
|
||||||
* unparseable, nothing is injected (the call goes out without identity and the
|
* unparseable, nothing is injected (the call goes out without identity and the
|
||||||
@ -44,6 +57,11 @@ import java.util.UUID;
|
|||||||
@Service
|
@Service
|
||||||
public class McpIdentityForwardService {
|
public class McpIdentityForwardService {
|
||||||
|
|
||||||
|
/** Trust levels carried in the JWT {@code trust} claim / plaintext prefix. */
|
||||||
|
static final String TRUST_AUTHENTICATED = "authenticated";
|
||||||
|
static final String TRUST_ANONYMOUS = "anonymous";
|
||||||
|
static final String TRUST_EXTERNAL = "external";
|
||||||
|
|
||||||
private final McpIdentityForwardProperties properties;
|
private final McpIdentityForwardProperties properties;
|
||||||
|
|
||||||
/** Lazily parsed signing key; {@code null} until first use / when unavailable. */
|
/** Lazily parsed signing key; {@code null} until first use / when unavailable. */
|
||||||
@ -65,20 +83,70 @@ public class McpIdentityForwardService {
|
|||||||
/** The (key, value) to merge into the call arguments, or empty to inject nothing. */
|
/** The (key, value) to merge into the call arguments, or empty to inject nothing. */
|
||||||
public record Injection(String key, String value) {}
|
public record Injection(String key, String value) {}
|
||||||
|
|
||||||
|
/** A typed identity resolved from the request origin. Empty = inject nothing. */
|
||||||
|
record ResolvedIdentity(String subject, String trust, String channelType) {
|
||||||
|
static final ResolvedIdentity NONE = new ResolvedIdentity(null, null, null);
|
||||||
|
boolean present() { return subject != null && !subject.isBlank(); }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the identity injection for a call. Empty when there is no
|
* Classify the requester behind a tool call into a typed identity. Returns
|
||||||
* authenticated user (never fabricate identity), or when token mode is
|
* {@link ResolvedIdentity#NONE} for cron / system / unattributed origins
|
||||||
* enabled but the key is unavailable (fail-closed).
|
* (never assert identity on behalf of a non-user).
|
||||||
|
*
|
||||||
|
* <p>Source channel is read from {@link ChatOrigin} (carried on the
|
||||||
|
* {@link ToolContext}); when the context carries no origin it falls back to
|
||||||
|
* the legacy {@link ToolExecutionContext} username (treated as web/authenticated
|
||||||
|
* only if non-blank — preserves the original contract for the ThreadLocal path).
|
||||||
|
*/
|
||||||
|
ResolvedIdentity classify(ToolContext ctx) {
|
||||||
|
ChatOrigin origin = ChatOrigin.from(ctx);
|
||||||
|
// Cron / system / unattributed: never forward identity.
|
||||||
|
if (origin.cronOrigin() || "system".equals(origin.requesterId())) {
|
||||||
|
return ResolvedIdentity.NONE;
|
||||||
|
}
|
||||||
|
String channel = origin.channelType();
|
||||||
|
// Authenticated web account: MateClaw vouches for this user. Prefer the
|
||||||
|
// immutable numeric id when available (web-console login); fall back to
|
||||||
|
// the username when only the ThreadLocal path supplied identity.
|
||||||
|
if (channel == null || channel.isBlank() || "web".equals(channel)) {
|
||||||
|
if (origin.requesterUserId() != null) {
|
||||||
|
return new ResolvedIdentity(String.valueOf(origin.requesterUserId()),
|
||||||
|
TRUST_AUTHENTICATED, "web");
|
||||||
|
}
|
||||||
|
String user = ToolExecutionContext.username(ctx);
|
||||||
|
return user != null && !user.isBlank()
|
||||||
|
? new ResolvedIdentity(user, TRUST_AUTHENTICATED, "web")
|
||||||
|
: ResolvedIdentity.NONE;
|
||||||
|
}
|
||||||
|
// webchat visitor ("api") or IM sender (feishu/wecom/…): external id,
|
||||||
|
// no MateClaw account — forward with an explicit trust downgrade so the
|
||||||
|
// backend knows it is NOT an authenticated MateClaw user.
|
||||||
|
String requester = origin.requesterId();
|
||||||
|
if (requester == null || requester.isBlank()) {
|
||||||
|
return ResolvedIdentity.NONE;
|
||||||
|
}
|
||||||
|
String trust = "api".equals(channel) ? TRUST_ANONYMOUS : TRUST_EXTERNAL;
|
||||||
|
return new ResolvedIdentity(requester, trust, channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the identity injection for a call. Empty when the origin carries
|
||||||
|
* no usable identity (cron / system / anonymous-without-id), or when token
|
||||||
|
* mode is enabled but the key is unavailable (fail-closed).
|
||||||
*/
|
*/
|
||||||
public Optional<Injection> resolve(ToolContext ctx, String audience) {
|
public Optional<Injection> resolve(ToolContext ctx, String audience) {
|
||||||
String user = ToolExecutionContext.username(ctx);
|
ResolvedIdentity id = classify(ctx);
|
||||||
if (user == null || user.isBlank()) {
|
if (!id.present()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
if (!properties.getToken().isEnabled()) {
|
if (!properties.getToken().isEnabled()) {
|
||||||
return Optional.of(new Injection(McpIdentityForwardProperties.USER_ARG, user));
|
// Plaintext: prefix the value with the trust level so the backend
|
||||||
|
// can tell authenticated from anonymous/external without a JWT.
|
||||||
|
return Optional.of(new Injection(McpIdentityForwardProperties.USER_ARG,
|
||||||
|
id.trust() + ":" + id.subject()));
|
||||||
}
|
}
|
||||||
String jwt = mint(user, audience);
|
String jwt = mint(id, audience);
|
||||||
if (jwt == null) {
|
if (jwt == null) {
|
||||||
return Optional.empty(); // fail-closed: token mode but no key
|
return Optional.empty(); // fail-closed: token mode but no key
|
||||||
}
|
}
|
||||||
@ -86,7 +154,7 @@ public class McpIdentityForwardService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Mint a short-lived RS256 JWT, or {@code null} if the key is unavailable. */
|
/** Mint a short-lived RS256 JWT, or {@code null} if the key is unavailable. */
|
||||||
private String mint(String subject, String audience) {
|
private String mint(ResolvedIdentity id, String audience) {
|
||||||
PrivateKey key = signingKey();
|
PrivateKey key = signingKey();
|
||||||
if (key == null) {
|
if (key == null) {
|
||||||
return null;
|
return null;
|
||||||
@ -97,8 +165,10 @@ public class McpIdentityForwardService {
|
|||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.header().keyId(t.getKeyId()).and()
|
.header().keyId(t.getKeyId()).and()
|
||||||
.issuer(t.getIssuer())
|
.issuer(t.getIssuer())
|
||||||
.subject(subject)
|
.subject(id.subject())
|
||||||
.audience().add(audience).and()
|
.audience().add(audience).and()
|
||||||
|
.claim("trust", id.trust())
|
||||||
|
.claim("channel_type", id.channelType())
|
||||||
.id(UUID.randomUUID().toString())
|
.id(UUID.randomUUID().toString())
|
||||||
.issuedAt(Date.from(now))
|
.issuedAt(Date.from(now))
|
||||||
.expiration(Date.from(now.plus(Duration.ofSeconds(Math.max(1, t.getTtlSeconds())))))
|
.expiration(Date.from(now.plus(Duration.ofSeconds(Math.max(1, t.getTtlSeconds())))))
|
||||||
|
|||||||
@ -41,7 +41,7 @@ class ChatOriginSenderFieldsTest {
|
|||||||
void withSenderPreservesOtherFields() {
|
void withSenderPreservesOtherFields() {
|
||||||
ChatOrigin original = new ChatOrigin(
|
ChatOrigin original = new ChatOrigin(
|
||||||
7L, "conv-1", "u123", 5L, "/ws", 9L, null, false,
|
7L, "conv-1", "u123", 5L, "/ws", 9L, null, false,
|
||||||
null, null, null, null);
|
null, null, null, null, null);
|
||||||
ChatOrigin enriched = original.withSender("Alice", "wecom", "g-1");
|
ChatOrigin enriched = original.withSender("Alice", "wecom", "g-1");
|
||||||
|
|
||||||
// All non-sender fields unchanged
|
// All non-sender fields unchanged
|
||||||
@ -80,7 +80,7 @@ class ChatOriginSenderFieldsTest {
|
|||||||
ChatOrigin origin = new ChatOrigin(
|
ChatOrigin origin = new ChatOrigin(
|
||||||
7L, "feishu:oc_42", "ou_xyz", 5L, "/data/ws/5",
|
7L, "feishu:oc_42", "ou_xyz", 5L, "/data/ws/5",
|
||||||
9L, null, false,
|
9L, null, false,
|
||||||
"Alice", "feishu", "oc_42", null);
|
"Alice", "feishu", "oc_42", null, null);
|
||||||
|
|
||||||
String json = om.writeValueAsString(origin);
|
String json = om.writeValueAsString(origin);
|
||||||
ChatOrigin restored = om.readValue(json, ChatOrigin.class);
|
ChatOrigin restored = om.readValue(json, ChatOrigin.class);
|
||||||
|
|||||||
@ -28,7 +28,7 @@ class ChatOriginTest {
|
|||||||
void roundTripThroughToolContext_preservesAllFields() {
|
void roundTripThroughToolContext_preservesAllFields() {
|
||||||
ChannelTarget target = new ChannelTarget("user-42", "thread-abc", "bot-001");
|
ChannelTarget target = new ChannelTarget("user-42", "thread-abc", "bot-001");
|
||||||
ChatOrigin original = new ChatOrigin(7L, "wechat:42", "u123", 5L,
|
ChatOrigin original = new ChatOrigin(7L, "wechat:42", "u123", 5L,
|
||||||
"/data/ws/5", 9L, target, false, null, null, null, null);
|
"/data/ws/5", 9L, target, false, null, null, null, null, null);
|
||||||
|
|
||||||
ToolContext ctx = original.toToolContext();
|
ToolContext ctx = original.toToolContext();
|
||||||
ChatOrigin restored = ChatOrigin.from(ctx);
|
ChatOrigin restored = ChatOrigin.from(ctx);
|
||||||
@ -75,7 +75,7 @@ class ChatOriginTest {
|
|||||||
void jsonSerialization_isStableAndForwardCompatible() throws Exception {
|
void jsonSerialization_isStableAndForwardCompatible() throws Exception {
|
||||||
ObjectMapper om = new ObjectMapper();
|
ObjectMapper om = new ObjectMapper();
|
||||||
ChatOrigin origin = new ChatOrigin(7L, "wechat:42", "u123", 5L,
|
ChatOrigin origin = new ChatOrigin(7L, "wechat:42", "u123", 5L,
|
||||||
"/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001"), false, null, null, null, null);
|
"/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001"), false, null, null, null, null, null);
|
||||||
|
|
||||||
String json = om.writeValueAsString(origin);
|
String json = om.writeValueAsString(origin);
|
||||||
ChatOrigin restored = om.readValue(json, ChatOrigin.class);
|
ChatOrigin restored = om.readValue(json, ChatOrigin.class);
|
||||||
|
|||||||
@ -65,7 +65,7 @@ class RuntimeContextInjectorModelTest {
|
|||||||
void imOriginHasSenderAndModel() {
|
void imOriginHasSenderAndModel() {
|
||||||
ChatOrigin origin = new ChatOrigin(
|
ChatOrigin origin = new ChatOrigin(
|
||||||
7L, "feishu:oc_abc", "ou_xyz", 5L, "/data/ws/5",
|
7L, "feishu:oc_abc", "ou_xyz", 5L, "/data/ws/5",
|
||||||
9L, null, false, "Alice", "feishu", "oc_abc", null);
|
9L, null, false, "Alice", "feishu", "oc_abc", null, null);
|
||||||
|
|
||||||
String ctx = RuntimeContextInjector.buildContextMessage(
|
String ctx = RuntimeContextInjector.buildContextMessage(
|
||||||
"/data/ws/5", null, origin, "gpt-4o", "openai");
|
"/data/ws/5", null, origin, "gpt-4o", "openai");
|
||||||
|
|||||||
@ -28,7 +28,7 @@ class RuntimeContextInjectorSenderTest {
|
|||||||
/* senderName */ "Alice",
|
/* senderName */ "Alice",
|
||||||
/* channelType */ "feishu",
|
/* channelType */ "feishu",
|
||||||
/* chatId */ "oc_abc",
|
/* chatId */ "oc_abc",
|
||||||
/* baseUrl */ null);
|
/* baseUrl */ null, null);
|
||||||
|
|
||||||
String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin);
|
String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin);
|
||||||
|
|
||||||
@ -45,7 +45,7 @@ class RuntimeContextInjectorSenderTest {
|
|||||||
ChatOrigin origin = new ChatOrigin(
|
ChatOrigin origin = new ChatOrigin(
|
||||||
7L, "feishu:ou_xyz", "ou_xyz", 5L, "/data/ws/5",
|
7L, "feishu:ou_xyz", "ou_xyz", 5L, "/data/ws/5",
|
||||||
9L, null, false,
|
9L, null, false,
|
||||||
"Alice", "feishu", null, null);
|
"Alice", "feishu", null, null, null);
|
||||||
|
|
||||||
String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin);
|
String ctx = RuntimeContextInjector.buildContextMessage("/data/ws/5", null, origin);
|
||||||
|
|
||||||
@ -102,7 +102,7 @@ class RuntimeContextInjectorSenderTest {
|
|||||||
void blankSenderName() {
|
void blankSenderName() {
|
||||||
ChatOrigin origin = new ChatOrigin(
|
ChatOrigin origin = new ChatOrigin(
|
||||||
7L, null, "ou_xyz", null, null, null, null, false,
|
7L, null, "ou_xyz", null, null, null, null, false,
|
||||||
/* senderName */ " ", "feishu", null, null);
|
/* senderName */ " ", "feishu", null, null, null);
|
||||||
|
|
||||||
String ctx = RuntimeContextInjector.buildContextMessage(null, null, origin);
|
String ctx = RuntimeContextInjector.buildContextMessage(null, null, origin);
|
||||||
|
|
||||||
|
|||||||
@ -45,7 +45,7 @@ class ApprovalReplayContinuityTest {
|
|||||||
/* senderName */ "Alice",
|
/* senderName */ "Alice",
|
||||||
/* channelType */ "wecom",
|
/* channelType */ "wecom",
|
||||||
/* chatId */ "group-a",
|
/* chatId */ "group-a",
|
||||||
/* baseUrl */ null);
|
/* baseUrl */ null, null);
|
||||||
|
|
||||||
String json = objectMapper.writeValueAsString(original);
|
String json = objectMapper.writeValueAsString(original);
|
||||||
ChatOrigin restored = workflow.restoreChatOrigin(json);
|
ChatOrigin restored = workflow.restoreChatOrigin(json);
|
||||||
|
|||||||
@ -42,7 +42,7 @@ class CronJobRunnerPromptTest {
|
|||||||
/* senderName */ null,
|
/* senderName */ null,
|
||||||
/* channelType */ "feishu",
|
/* channelType */ "feishu",
|
||||||
/* chatId */ "group-a",
|
/* chatId */ "group-a",
|
||||||
/* baseUrl */ null);
|
/* baseUrl */ null, null);
|
||||||
String prompt = CronJobRunner.buildCronPrompt("提醒喝水", channelOrigin);
|
String prompt = CronJobRunner.buildCronPrompt("提醒喝水", channelOrigin);
|
||||||
|
|
||||||
assertTrue(prompt.contains("[定时任务执行说明]"));
|
assertTrue(prompt.contains("[定时任务执行说明]"));
|
||||||
|
|||||||
@ -196,7 +196,7 @@ class DelegateAsyncTaskOutputAttributionTest {
|
|||||||
|
|
||||||
private ToolContext makeCtx(String requester, String conversationId) {
|
private ToolContext makeCtx(String requester, String conversationId) {
|
||||||
ChatOrigin origin = new ChatOrigin(
|
ChatOrigin origin = new ChatOrigin(
|
||||||
1L, conversationId, requester, null, null, null, null, false, null, null, null, null);
|
1L, conversationId, requester, null, null, null, null, false, null, null, null, null, null);
|
||||||
Map<String, Object> map = new HashMap<>();
|
Map<String, Object> map = new HashMap<>();
|
||||||
map.put(ChatOrigin.CTX_KEY, origin);
|
map.put(ChatOrigin.CTX_KEY, origin);
|
||||||
return new ToolContext(map);
|
return new ToolContext(map);
|
||||||
|
|||||||
@ -389,7 +389,7 @@ class DelegateAsyncToolTest {
|
|||||||
|
|
||||||
private ToolContext makeCtx(String requester, String conversationId) {
|
private ToolContext makeCtx(String requester, String conversationId) {
|
||||||
ChatOrigin origin = new ChatOrigin(
|
ChatOrigin origin = new ChatOrigin(
|
||||||
1L, conversationId, requester, null, null, null, null, false, null, null, null, null);
|
1L, conversationId, requester, null, null, null, null, false, null, null, null, null, null);
|
||||||
Map<String, Object> map = new HashMap<>();
|
Map<String, Object> map = new HashMap<>();
|
||||||
map.put(ChatOrigin.CTX_KEY, origin);
|
map.put(ChatOrigin.CTX_KEY, origin);
|
||||||
return new ToolContext(map);
|
return new ToolContext(map);
|
||||||
|
|||||||
@ -4,7 +4,10 @@ import io.jsonwebtoken.Claims;
|
|||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||||
|
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
@ -17,9 +20,13 @@ import java.util.Optional;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests {@link McpIdentityForwardService}: plaintext vs signed-token resolution,
|
* Tests {@link McpIdentityForwardService}: identity typing across channels,
|
||||||
* fail-closed when token mode lacks a key, and that a minted token verifies
|
* plaintext vs signed-token resolution, and fail-closed behaviour.
|
||||||
* against the public key with the right claims.
|
*
|
||||||
|
* <p>The core of this suite is the {@code trust}/{@code channel_type} typing —
|
||||||
|
* making sure an authenticated MateClaw user, a webchat visitor, an IM sender,
|
||||||
|
* and a cron run are forwarded with distinguishable, non-fabricated identities
|
||||||
|
* (see RFC: on-behalf-of identity typing, issue #459 review).
|
||||||
*/
|
*/
|
||||||
class McpIdentityForwardServiceTest {
|
class McpIdentityForwardServiceTest {
|
||||||
|
|
||||||
@ -32,50 +39,149 @@ class McpIdentityForwardServiceTest {
|
|||||||
return new McpIdentityForwardService(p);
|
return new McpIdentityForwardService(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
/** Build a ToolContext carrying the given origin, mirroring ToolExecutionExecutor. */
|
||||||
@DisplayName("plaintext mode: injects username under USER_ARG")
|
private static ToolContext ctx(ChatOrigin origin) {
|
||||||
void plaintext() {
|
return new ToolContext(Map.of(ChatOrigin.CTX_KEY, origin));
|
||||||
ToolExecutionContext.set("c1", "alice");
|
|
||||||
var p = new McpIdentityForwardProperties();
|
|
||||||
Optional<McpIdentityForwardService.Injection> inj = svc(p).resolve(null, "my-api");
|
|
||||||
assertThat(inj).isPresent();
|
|
||||||
assertThat(inj.get().key()).isEqualTo(McpIdentityForwardProperties.USER_ARG);
|
|
||||||
assertThat(inj.get().value()).isEqualTo("alice");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
private static KeyPair rsaKeyPair() {
|
||||||
@DisplayName("no authenticated user: nothing injected")
|
try {
|
||||||
void noUser() {
|
return KeyPairGenerator.getInstance("RSA").genKeyPair();
|
||||||
var p = new McpIdentityForwardProperties();
|
} catch (Exception e) {
|
||||||
assertThat(svc(p).resolve(null, "my-api")).isEmpty();
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
/** Token-mode config signed with {@code kp}'s private key. */
|
||||||
@DisplayName("token mode but no key: fail-closed (nothing injected)")
|
private static McpIdentityForwardProperties tokenProps(KeyPair kp) {
|
||||||
void tokenModeNoKey() {
|
|
||||||
ToolExecutionContext.set("c1", "alice");
|
|
||||||
var p = new McpIdentityForwardProperties();
|
|
||||||
p.getToken().setEnabled(true); // no private-key-pem
|
|
||||||
assertThat(svc(p).resolve(null, "my-api")).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("token mode: mints an RS256 JWT that verifies with the public key")
|
|
||||||
void tokenMintAndVerify() throws Exception {
|
|
||||||
KeyPair kp = KeyPairGenerator.getInstance("RSA").genKeyPair(); // 2048 default
|
|
||||||
var p = new McpIdentityForwardProperties();
|
var p = new McpIdentityForwardProperties();
|
||||||
p.getToken().setEnabled(true);
|
p.getToken().setEnabled(true);
|
||||||
p.getToken().setIssuer("mateclaw");
|
p.getToken().setIssuer("mateclaw");
|
||||||
p.getToken().setTtlSeconds(60);
|
p.getToken().setTtlSeconds(60);
|
||||||
p.getToken().setPrivateKeyPem(Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded()));
|
p.getToken().setPrivateKeyPem(Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded()));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
ToolExecutionContext.set("c1", "alice");
|
// ==================== Identity typing across channels ====================
|
||||||
Optional<McpIdentityForwardService.Injection> inj = svc(p).resolve(null, "my-api");
|
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("classify: identity typing per channel")
|
||||||
|
class Classify {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("authenticated web user → sub=userId, trust=authenticated")
|
||||||
|
void authenticatedWebUser() {
|
||||||
|
ChatOrigin origin = ChatOrigin.web("c1", "alice", 1L, null, null, 42L);
|
||||||
|
McpIdentityForwardService.ResolvedIdentity id = svc(new McpIdentityForwardProperties()).classify(ctx(origin));
|
||||||
|
assertThat(id.subject()).isEqualTo("42");
|
||||||
|
assertThat(id.trust()).isEqualTo(McpIdentityForwardService.TRUST_AUTHENTICATED);
|
||||||
|
assertThat(id.channelType()).isEqualTo("web");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("webchat visitor (channelType=api) → trust=anonymous, sub=visitorId")
|
||||||
|
void webchatVisitor() {
|
||||||
|
// webchat sets requesterId=visitorId and channelType=api via withSender
|
||||||
|
ChatOrigin origin = ChatOrigin.web("c1", "visitor-xyz", 1L, null)
|
||||||
|
.withSender(null, "api", null);
|
||||||
|
McpIdentityForwardService.ResolvedIdentity id = svc(new McpIdentityForwardProperties()).classify(ctx(origin));
|
||||||
|
assertThat(id.subject()).isEqualTo("visitor-xyz");
|
||||||
|
assertThat(id.trust()).isEqualTo(McpIdentityForwardService.TRUST_ANONYMOUS);
|
||||||
|
assertThat(id.channelType()).isEqualTo("api");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("IM sender (channelType=feishu) → trust=external")
|
||||||
|
void imSender() {
|
||||||
|
ChatOrigin origin = new ChatOrigin(null, "c1", "im_user_1", 1L, null,
|
||||||
|
9L, null, false, "张三", "feishu", "grp1", null, null);
|
||||||
|
McpIdentityForwardService.ResolvedIdentity id = svc(new McpIdentityForwardProperties()).classify(ctx(origin));
|
||||||
|
assertThat(id.subject()).isEqualTo("im_user_1");
|
||||||
|
assertThat(id.trust()).isEqualTo(McpIdentityForwardService.TRUST_EXTERNAL);
|
||||||
|
assertThat(id.channelType()).isEqualTo("feishu");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("cron origin → NONE (never assert identity for a non-user)")
|
||||||
|
void cronOrigin() {
|
||||||
|
ChatOrigin origin = ChatOrigin.cron("c1", 1L, null, 9L, null);
|
||||||
|
assertThat(svc(new McpIdentityForwardProperties()).classify(ctx(origin)))
|
||||||
|
.isEqualTo(McpIdentityForwardService.ResolvedIdentity.NONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("system requesterId → NONE")
|
||||||
|
void systemRequester() {
|
||||||
|
// An origin whose requesterId is "system" but cronOrigin=false (defensive)
|
||||||
|
ChatOrigin origin = new ChatOrigin(null, "c1", "system", 1L, null,
|
||||||
|
null, null, false, null, null, null, null, null);
|
||||||
|
assertThat(svc(new McpIdentityForwardProperties()).classify(ctx(origin)))
|
||||||
|
.isEqualTo(McpIdentityForwardService.ResolvedIdentity.NONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("web origin without userId falls back to ThreadLocal username (legacy path)")
|
||||||
|
void webOriginLegacyThreadLocal() {
|
||||||
|
ToolExecutionContext.set("c1", "alice");
|
||||||
|
// 5-arg web(): no requesterUserId → falls back to ThreadLocal
|
||||||
|
ChatOrigin origin = ChatOrigin.web("c1", "alice", 1L, null, null);
|
||||||
|
McpIdentityForwardService.ResolvedIdentity id = svc(new McpIdentityForwardProperties()).classify(ctx(origin));
|
||||||
|
assertThat(id.subject()).isEqualTo("alice");
|
||||||
|
assertThat(id.trust()).isEqualTo(McpIdentityForwardService.TRUST_AUTHENTICATED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Resolution: plaintext & token modes ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("plaintext mode: injects '<trust>:<subject>' under USER_ARG")
|
||||||
|
void plaintextTyped() {
|
||||||
|
ChatOrigin origin = ChatOrigin.web("c1", "alice", 1L, null, null, 42L);
|
||||||
|
var p = new McpIdentityForwardProperties();
|
||||||
|
Optional<McpIdentityForwardService.Injection> inj = svc(p).resolve(ctx(origin), "my-api");
|
||||||
|
assertThat(inj).isPresent();
|
||||||
|
assertThat(inj.get().key()).isEqualTo(McpIdentityForwardProperties.USER_ARG);
|
||||||
|
assertThat(inj.get().value()).isEqualTo("authenticated:42");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("plaintext mode: anonymous visitor carries trust=anonymous prefix")
|
||||||
|
void plaintextAnonymous() {
|
||||||
|
ChatOrigin origin = ChatOrigin.web("c1", "visitor-xyz", 1L, null).withSender(null, "api", null);
|
||||||
|
Optional<McpIdentityForwardService.Injection> inj =
|
||||||
|
svc(new McpIdentityForwardProperties()).resolve(ctx(origin), "my-api");
|
||||||
|
assertThat(inj).isPresent();
|
||||||
|
assertThat(inj.get().value()).startsWith("anonymous:visitor-xyz");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("no usable identity (cron): nothing injected")
|
||||||
|
void noIdentity() {
|
||||||
|
ChatOrigin origin = ChatOrigin.cron("c1", 1L, null, 9L, null);
|
||||||
|
assertThat(svc(new McpIdentityForwardProperties()).resolve(ctx(origin), "my-api")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("token mode but no key: fail-closed (nothing injected)")
|
||||||
|
void tokenModeNoKey() {
|
||||||
|
ChatOrigin origin = ChatOrigin.web("c1", "alice", 1L, null, null, 42L);
|
||||||
|
var p = new McpIdentityForwardProperties();
|
||||||
|
p.getToken().setEnabled(true); // no private-key-pem
|
||||||
|
assertThat(svc(p).resolve(ctx(origin), "my-api")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("token mode: mints RS256 JWT that verifies with trust/channel_type claims")
|
||||||
|
void tokenMintAndVerify() throws Exception {
|
||||||
|
KeyPair kp = rsaKeyPair();
|
||||||
|
var p = tokenProps(kp);
|
||||||
|
ChatOrigin origin = ChatOrigin.web("c1", "alice", 1L, null, null, 42L);
|
||||||
|
|
||||||
|
Optional<McpIdentityForwardService.Injection> inj = svc(p).resolve(ctx(origin), "my-api");
|
||||||
assertThat(inj).isPresent();
|
assertThat(inj).isPresent();
|
||||||
assertThat(inj.get().key()).isEqualTo(McpIdentityForwardProperties.TOKEN_ARG);
|
assertThat(inj.get().key()).isEqualTo(McpIdentityForwardProperties.TOKEN_ARG);
|
||||||
|
|
||||||
// The REST backend would do exactly this: verify with the public key.
|
// The REST backend verifies with the public key and reads the typed claims.
|
||||||
Claims claims = Jwts.parser()
|
Claims claims = Jwts.parser()
|
||||||
.verifyWith(kp.getPublic())
|
.verifyWith(kp.getPublic())
|
||||||
.requireIssuer("mateclaw")
|
.requireIssuer("mateclaw")
|
||||||
@ -84,9 +190,31 @@ class McpIdentityForwardServiceTest {
|
|||||||
.parseSignedClaims(inj.get().value())
|
.parseSignedClaims(inj.get().value())
|
||||||
.getPayload();
|
.getPayload();
|
||||||
|
|
||||||
assertThat(claims.getSubject()).isEqualTo("alice");
|
assertThat(claims.getSubject()).isEqualTo("42");
|
||||||
|
assertThat(claims.get("trust", String.class)).isEqualTo(McpIdentityForwardService.TRUST_AUTHENTICATED);
|
||||||
|
assertThat(claims.get("channel_type", String.class)).isEqualTo("web");
|
||||||
assertThat(claims.getExpiration()).isAfter(new Date());
|
assertThat(claims.getExpiration()).isAfter(new Date());
|
||||||
assertThat(claims.getId()).isNotBlank(); // jti present
|
assertThat(claims.getId()).isNotBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("token mode: anonymous visitor token carries trust=anonymous")
|
||||||
|
void tokenAnonymousVisitor() throws Exception {
|
||||||
|
KeyPair kp = rsaKeyPair();
|
||||||
|
var p = tokenProps(kp);
|
||||||
|
ChatOrigin origin = ChatOrigin.web("c1", "visitor-xyz", 1L, null).withSender(null, "api", null);
|
||||||
|
|
||||||
|
Optional<McpIdentityForwardService.Injection> inj = svc(p).resolve(ctx(origin), "my-api");
|
||||||
|
assertThat(inj).isPresent();
|
||||||
|
|
||||||
|
Claims claims = Jwts.parser()
|
||||||
|
.verifyWith(kp.getPublic())
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(inj.get().value())
|
||||||
|
.getPayload();
|
||||||
|
assertThat(claims.getSubject()).isEqualTo("visitor-xyz");
|
||||||
|
assertThat(claims.get("trust", String.class)).isEqualTo(McpIdentityForwardService.TRUST_ANONYMOUS);
|
||||||
|
assertThat(claims.get("channel_type", String.class)).isEqualTo("api");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user