mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +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.
120 lines
3.9 KiB
Go
120 lines
3.9 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/security"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// recordingMCPOAuthProvider records the userID arg passed to GetValidToken so we
|
|
// can assert the scope (global "" vs per-user) used by the preview endpoints.
|
|
type recordingMCPOAuthProvider struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
lastUserID string
|
|
token string
|
|
}
|
|
|
|
func (p *recordingMCPOAuthProvider) GetValidToken(_ context.Context, _ uuid.UUID, _ uuid.UUID, userID string) (string, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.calls++
|
|
p.lastUserID = userID
|
|
return p.token, nil
|
|
}
|
|
|
|
// oauthServerWithRequireUserCreds returns an OAuth server that ALSO has
|
|
// require_user_credentials=true — the case where the old code wrongly used the
|
|
// caller's per-user token scope for discovery/test.
|
|
func oauthServerWithRequireUserCreds(id uuid.UUID) *store.MCPServerData {
|
|
return &store.MCPServerData{
|
|
BaseModel: store.BaseModel{ID: id},
|
|
Name: "oauth-srv",
|
|
Transport: "streamable-http",
|
|
URL: "http://127.0.0.1:1/mcp",
|
|
Settings: json.RawMessage(`{"oauth":{"auth_type":"oauth"},"require_user_credentials":true}`),
|
|
}
|
|
}
|
|
|
|
// ctxWithUser attaches a tenant + caller user id (the old code would have used
|
|
// this caller id as the OAuth scope).
|
|
func ctxWithUser(r *http.Request, userID string) *http.Request {
|
|
ctx := store.WithTenantID(store.WithUserID(r.Context(), userID), uuid.New())
|
|
return r.WithContext(ctx)
|
|
}
|
|
|
|
// list-tools must use the GLOBAL OAuth token scope (userID="") even when the
|
|
// server has require_user_credentials=true — discovery is server-wide.
|
|
func TestHandleListServerToolsUsesGlobalOAuthScope(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
|
|
st := newMockMCPServerForOAuth()
|
|
id := uuid.New()
|
|
st.servers[id] = oauthServerWithRequireUserCreds(id)
|
|
|
|
prov := &recordingMCPOAuthProvider{token: ""} // not authorized → discovery returns empty
|
|
h := NewMCPHandler(st, nil, nil)
|
|
h.SetOAuthProvider(prov)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/mcp/servers/"+id.String()+"/tools", nil)
|
|
req.SetPathValue("id", id.String())
|
|
req = ctxWithUser(req, "alice")
|
|
rec := httptest.NewRecorder()
|
|
h.handleListServerTools(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
prov.mu.Lock()
|
|
defer prov.mu.Unlock()
|
|
if prov.calls == 0 {
|
|
t.Fatal("expected GetValidToken to be called for OAuth server")
|
|
}
|
|
if prov.lastUserID != "" {
|
|
t.Errorf("list-tools must use GLOBAL scope (userID=\"\"), got %q", prov.lastUserID)
|
|
}
|
|
}
|
|
|
|
// test-connection must use the GLOBAL OAuth token scope (userID="") for OAuth
|
|
// servers, regardless of require_user_credentials.
|
|
func TestHandleTestConnectionUsesGlobalOAuthScope(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
|
|
st := newMockMCPServerForOAuth()
|
|
id := uuid.New()
|
|
st.servers[id] = oauthServerWithRequireUserCreds(id)
|
|
|
|
prov := &recordingMCPOAuthProvider{token: ""}
|
|
h := NewMCPHandler(st, nil, nil)
|
|
h.SetOAuthProvider(prov)
|
|
|
|
body := `{"server_id":"` + id.String() + `","transport":"streamable-http","url":"http://127.0.0.1:1/mcp","headers":{"Authorization":"Bearer body-token"}}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/mcp/servers/test", strings.NewReader(body))
|
|
req = ctxWithUser(req, "alice")
|
|
rec := httptest.NewRecorder()
|
|
h.handleTestConnection(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
prov.mu.Lock()
|
|
defer prov.mu.Unlock()
|
|
if prov.calls == 0 {
|
|
t.Fatal("expected GetValidToken to be called for OAuth server")
|
|
}
|
|
if prov.lastUserID != "" {
|
|
t.Errorf("test-connection must use GLOBAL scope (userID=\"\"), got %q", prov.lastUserID)
|
|
}
|
|
}
|