mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 12:13:15 +00:00
* feat(mcp): MCP OAuth 2.1 client — full implementation with tests
Implements a complete MCP OAuth 2.1 authorization flow for tool servers that
require user-delegated access, covering all layers from DB to UI.
- discovery.go: RFC 9728 protected-resource → RFC 8414 AS metadata → OIDC
fallback chain with 5-min in-memory cache and InvalidateCache()
- dcr.go: RFC 7591 Dynamic Client Registration with response size guard
- flow.go: PKCE (S256) authorization code flow — StartFlow(), ExchangeCode(),
ClientCredentials(), auto-cleanup of expired flows; carries AS issuer through
PendingFlow for status display
- refresher.go: OAuthTokenProvider with in-memory token cache, automatic refresh
on expiry, per-user vs global slot isolation, InvalidateCache/InvalidateServer
- migrations/000074 + SQLite schema: mcp_oauth_tokens with AES-256-GCM encrypted
access/refresh tokens, partial unique index for global vs per-user rows,
ON DELETE CASCADE from mcp_servers
- store.MCPOAuthTokenStore: Upsert, Get/GetUser, Delete/DeleteUser, and
DeleteServerOAuthTokens (purge all rows for a server)
- PostgreSQL + SQLite implementations
- POST /v1/mcp/oauth/start — discovery + optional DCR + PKCE redirect URL;
client_credentials completes server-side (no redirect) and returns completed=true
- GET /v1/mcp/oauth/callback — exchange code, persist token, publish WS event;
payload built via json.Marshal (no reflected XSS via error_description)
- GET /v1/mcp/oauth/status/{id}, DELETE /v1/mcp/oauth/token/{id} — admin-gated
- POST /v1/mcp/oauth/discover/{id} — on-demand discovery probe
- All outbound calls go through the SSRF-safe client with pinned IPs
- pkg/protocol/mcp_events.go: EventMCPOAuthComplete routed only to the initiating
user (admins in-tenant included); fail-closed across tenants
- getUserMCPTools() injects Authorization: Bearer from OAuthTokenProvider; on a
401 for OAuth servers it purges the cached token so the next turn re-resolves
- handleUpdateServer purges all OAuth tokens (global + per-user), drops the
refresher cache, and evicts the pool when a server's URL or OAuth config
(client_id / endpoints / grant_type / scope / auth_type) changes — so the
status UI and agent never use a token minted for the old resource/AS
- MCPOAuthDialog (WS-driven), unified user-credentials dialog, OAuth settings
fields; handles the no-redirect client_credentials completion
- internal/mcp/oauth/*_test.go: discovery cache, PKCE, DCR, refresher
- internal/http/mcp_oauth_test.go + mcp_update_oauth_purge_test.go: routes, auth
gating, WS event, purge-on-URL/OAuth-config-change
- tests/integration: store + encryption + tenant isolation, E2E start→callback,
DeleteServerOAuthTokens
- internal/gateway/event_filter_test.go, internal/agent/loop_mcp_user_test.go
* fix(mcp): return 400 on OAuth callback with code but missing state
The callback handler rendered a 200 HTML page whenever code or state was
absent. An auth code WITH a missing state is a malformed / CSRF-risk
callback (state is the CSRF token), so reject that case with HTTP 400.
A bare hit with neither code nor state (user opening the URL directly),
provider errors, and exchange failures keep their 200 HTML popup page.
Adds a status code parameter to writeCallbackHTML. Fixes the
TestOAuthCallbackMissingState integration regression while keeping
TestHandleCallbackMissingCodeAndState (no params -> 200) green.
* fix(mcp): scope-based OAuth auth + honor manual OAuth endpoints
Addresses the two MCP/OAuth security-review findings.
Finding 1 — authorization. mcp_oauth_tokens is tenant-scoped, but
start/status/revoke were gated only by requireAuth(RoleAdmin), an RBAC
role check, not tenant membership, so a RoleAdmin caller could act on a
tenant they don't administer. A blanket requireTenantAdmin would have
broken per-user self-service, which the UI exposes (the per-user
MCPUserCredentialsDialog shows an "Authorize" button to regular users for
their own credentials). Instead mirror the existing per-user MCP
credentials model (resolveTargetUserID in mcp_user_credentials.go):
- start/status/revoke accept any authenticated user; each handler calls
authorizeOAuthScope.
- a caller may manage their OWN per-user token (self-service); the
global/server token (user_id="") and other users' tokens require
tenant-admin (owner bypass), so a RoleAdmin that is not a tenant admin
is rejected.
- discover stays admin-only (it only previews AS metadata for a server).
Add a TenantStore dependency. Tests cover self-service, on-behalf-of-
another (403), and global-by-non-tenant-admin (403).
Finding 2 — honor manual OAuth config end-to-end. The UI sent use_dcr /
auth_endpoint / token_endpoint and the update path fingerprinted them for
purge, but handleStart always discovered + DCR'd and ignored them. Now:
- use_dcr=false (a *bool, so legacy/absent stays discover+DCR) skips
discovery/registration and uses the operator endpoints, SSRF-validated.
- token_endpoint is always required; auth_endpoint only for auth-code
grants — client_credentials needs no authorization URL, matching the UI
which hides that field for that grant.
- the refresher already refreshes against the stored token_endpoint and
the callback persists it, so manual-mode tokens refresh correctly.
- oauthFingerprint includes use_dcr (nil normalized to true) so toggling
DCR mode purges stale tokens.
- the web form only serializes manual endpoints when use_dcr is off.
Audited all MCP dialogs (form, global OAuth, per-user credentials, grants,
tools): OAuth dialogs handle completed/auth_url identically and read
config from stored server settings; runtime connect uses the stored token
via the refresher (no re-discovery).
Tests: manual auth-code + client_credentials endpoints, missing/SSRF
endpoints, and the full self/global/on-behalf authorization matrix.
155 lines
5.9 KiB
Go
155 lines
5.9 KiB
Go
package protocol
|
|
|
|
// WebSocket event names pushed from server to client.
|
|
const (
|
|
EventAgent = "agent"
|
|
EventChat = "chat"
|
|
EventHealth = "health"
|
|
EventCron = "cron"
|
|
EventHeartbeat = "heartbeat"
|
|
EventExecApprovalReq = "exec.approval.requested"
|
|
EventExecApprovalRes = "exec.approval.resolved"
|
|
EventPresence = "presence"
|
|
EventTick = "tick"
|
|
EventShutdown = "shutdown"
|
|
EventNodePairRequested = "node.pair.requested"
|
|
EventNodePairResolved = "node.pair.resolved"
|
|
EventDevicePairReq = "device.pair.requested"
|
|
EventDevicePairRes = "device.pair.resolved"
|
|
EventVoicewakeChanged = "voicewake.changed"
|
|
EventConnectChallenge = "connect.challenge"
|
|
EventTalkMode = "talk.mode"
|
|
|
|
// Agent summoning events (predefined agent setup via LLM).
|
|
EventAgentSummoning = "agent.summoning"
|
|
|
|
// Team activity events (real-time team workflow visibility).
|
|
EventTeamTaskCreated = "team.task.created"
|
|
EventTeamTaskCompleted = "team.task.completed"
|
|
EventTeamMessageSent = "team.message.sent"
|
|
EventDelegationStarted = "delegation.started"
|
|
EventDelegationCompleted = "delegation.completed"
|
|
|
|
// Delegation lifecycle events.
|
|
EventDelegationFailed = "delegation.failed"
|
|
EventDelegationCancelled = "delegation.cancelled"
|
|
EventDelegationProgress = "delegation.progress"
|
|
EventDelegationAccumulated = "delegation.accumulated"
|
|
EventDelegationAnnounce = "delegation.announce"
|
|
|
|
// Team task lifecycle events.
|
|
EventTeamTaskClaimed = "team.task.claimed"
|
|
EventTeamTaskCancelled = "team.task.cancelled"
|
|
EventTeamTaskFailed = "team.task.failed"
|
|
EventTeamTaskReviewed = "team.task.reviewed"
|
|
EventTeamTaskApproved = "team.task.approved"
|
|
EventTeamTaskRejected = "team.task.rejected"
|
|
EventTeamTaskProgress = "team.task.progress"
|
|
EventTeamTaskCommented = "team.task.commented"
|
|
EventTeamTaskAssigned = "team.task.assigned"
|
|
EventTeamTaskDispatched = "team.task.dispatched"
|
|
EventTeamTaskUpdated = "team.task.updated"
|
|
EventTeamTaskDeleted = "team.task.deleted"
|
|
EventTeamTaskStale = "team.task.stale"
|
|
EventTeamTaskAttachmentAdded = "team.task.attachment_added"
|
|
|
|
// Emitted when leader starts processing completed team task results (before announce run).
|
|
EventTeamLeaderProcessing = "team.leader.processing"
|
|
|
|
// Team CRUD events (admin operations).
|
|
EventTeamCreated = "team.created"
|
|
EventTeamUpdated = "team.updated"
|
|
EventTeamDeleted = "team.deleted"
|
|
EventTeamMemberAdded = "team.member.added"
|
|
EventTeamMemberRemoved = "team.member.removed"
|
|
|
|
// Workspace events (team file changes).
|
|
EventWorkspaceFileChanged = "workspace.file.changed"
|
|
|
|
// Agent link events (admin operations).
|
|
EventAgentLinkCreated = "agent_link.created"
|
|
EventAgentLinkUpdated = "agent_link.updated"
|
|
EventAgentLinkDeleted = "agent_link.deleted"
|
|
|
|
// Trace lifecycle events (realtime trace/span updates).
|
|
EventTraceUpdated = "trace.updated"
|
|
// Immediate status change event (not flush-buffered; fired on every status write).
|
|
EventTraceStatusChanged = "trace.status"
|
|
|
|
// Skill dependency check events (realtime progress during startup/rescan).
|
|
EventSkillDepsChecked = "skill.deps.checked"
|
|
EventSkillDepsComplete = "skill.deps.complete"
|
|
|
|
// Skill dependency install events (triggered by POST /v1/skills/install-deps).
|
|
EventSkillDepsInstalling = "skill.deps.installing"
|
|
EventSkillDepsInstalled = "skill.deps.installed"
|
|
|
|
// Per-item install events (triggered by POST /v1/skills/install-dep).
|
|
EventSkillDepItemInstalling = "skill.dep.item.installing" // payload: {dep: "pip:openpyxl"}
|
|
EventSkillDepItemInstalled = "skill.dep.item.installed" // payload: {dep, ok: bool, error?: string}
|
|
|
|
// Cache invalidation events (internal, not forwarded to WS clients).
|
|
EventCacheInvalidate = "cache.invalidate"
|
|
|
|
// Audit log event (internal, not forwarded to WS clients).
|
|
EventAuditLog = "audit.log"
|
|
|
|
// Session lifecycle events.
|
|
EventSessionUpdated = "session.updated"
|
|
|
|
// Zalo Personal QR login events (client-scoped, not broadcast).
|
|
EventZaloPersonalQRCode = "zalo.personal.qr.code"
|
|
EventZaloPersonalQRDone = "zalo.personal.qr.done"
|
|
|
|
// WhatsApp QR login events (client-scoped, not broadcast).
|
|
EventWhatsAppQRCode = "whatsapp.qr.code"
|
|
EventWhatsAppQRDone = "whatsapp.qr.done"
|
|
|
|
// Tenant access revocation — forces affected user's UI to logout.
|
|
EventTenantAccessRevoked = "tenant.access.revoked"
|
|
|
|
// Vault enrichment pipeline progress.
|
|
EventVaultEnrichProgress = "vault.enrich.progress"
|
|
|
|
// Background worker alerts (non-retryable LLM errors).
|
|
EventBackgroundError = "background.error"
|
|
|
|
// Workstation exec streaming events.
|
|
// EventWorkstationExecChunk is emitted for each stdout/stderr chunk during remote exec.
|
|
// Payload: WorkstationExecChunkPayload.
|
|
EventWorkstationExecChunk = "workstation.exec.chunk"
|
|
// EventWorkstationExecDone is emitted when a remote exec command finishes.
|
|
// Payload: WorkstationExecDonePayload.
|
|
EventWorkstationExecDone = "workstation.exec.done"
|
|
|
|
// MCP OAuth flow completion — fired after callback token exchange succeeds or fails.
|
|
// Payload: MCPOAuthCompletePayload.
|
|
EventMCPOAuthComplete = "mcp.oauth_complete"
|
|
)
|
|
|
|
// Agent event subtypes (in payload.type)
|
|
const (
|
|
AgentEventRunStarted = "run.started"
|
|
AgentEventRunCompleted = "run.completed"
|
|
AgentEventRunFailed = "run.failed"
|
|
AgentEventRunCancelled = "run.cancelled"
|
|
AgentEventRunRetrying = "run.retrying"
|
|
AgentEventToolCall = "tool.call"
|
|
AgentEventToolResult = "tool.result"
|
|
AgentEventBlockReply = "block.reply"
|
|
AgentEventActivity = "activity" // agent phase transitions: thinking, tool_exec, compacting
|
|
)
|
|
|
|
// block.reply payload source values.
|
|
const (
|
|
BlockReplySourceLLMProgress = "llm_progress"
|
|
BlockReplySourceToolAnnouncement = "tool_announcement"
|
|
)
|
|
|
|
// Chat event subtypes (in payload.type)
|
|
const (
|
|
ChatEventChunk = "chunk"
|
|
ChatEventMessage = "message"
|
|
ChatEventThinking = "thinking"
|
|
)
|