Files
thotam 0ae55991bb feat(mcp): MCP OAuth 2.1 client for tool servers (#1196)
* 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.
2026-06-21 22:27:44 +07:00

358 lines
9.5 KiB
Go

package mcpoauth
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/security"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// mockOAuthStore is a minimal in-memory implementation of store.MCPOAuthTokenStore.
type mockOAuthStore struct {
global map[string]*store.MCPOAuthToken // key: serverID
perUser map[string]*store.MCPOAuthToken // key: serverID+":"+userID
upserts int
}
func newMockOAuthStore() *mockOAuthStore {
return &mockOAuthStore{
global: make(map[string]*store.MCPOAuthToken),
perUser: make(map[string]*store.MCPOAuthToken),
}
}
func (m *mockOAuthStore) GetOAuthToken(_ context.Context, serverID, _ uuid.UUID) (*store.MCPOAuthToken, error) {
tok, ok := m.global[serverID.String()]
if !ok {
return nil, nil
}
cp := *tok
return &cp, nil
}
func (m *mockOAuthStore) GetUserOAuthToken(_ context.Context, serverID, _ uuid.UUID, userID string) (*store.MCPOAuthToken, error) {
tok, ok := m.perUser[serverID.String()+":"+userID]
if !ok {
return nil, nil
}
cp := *tok
return &cp, nil
}
func (m *mockOAuthStore) UpsertOAuthToken(_ context.Context, tok *store.MCPOAuthToken) error {
m.upserts++
cp := *tok
if tok.UserID == "" {
m.global[tok.ServerID.String()] = &cp
} else {
m.perUser[tok.ServerID.String()+":"+tok.UserID] = &cp
}
return nil
}
func (m *mockOAuthStore) DeleteOAuthToken(_ context.Context, serverID, _ uuid.UUID) error {
delete(m.global, serverID.String())
return nil
}
func (m *mockOAuthStore) DeleteUserOAuthToken(_ context.Context, serverID, _ uuid.UUID, userID string) error {
delete(m.perUser, serverID.String()+":"+userID)
return nil
}
func (m *mockOAuthStore) DeleteServerOAuthTokens(_ context.Context, serverID, _ uuid.UUID) error {
delete(m.global, serverID.String())
prefix := serverID.String() + ":"
for k := range m.perUser {
if strings.HasPrefix(k, prefix) {
delete(m.perUser, k)
}
}
return nil
}
// futureTime returns a time this far in the future.
func futureTime(d time.Duration) *time.Time {
t := time.Now().Add(d)
return &t
}
// pastTime returns a time this far in the past.
func pastTime(d time.Duration) *time.Time {
t := time.Now().Add(-d)
return &t
}
func TestGetValidTokenCacheHit(t *testing.T) {
st := newMockOAuthStore()
r := NewRefresher(st, http.DefaultClient)
serverID := uuid.New()
tenantID := uuid.New()
// Seed the store with a valid token.
validTok := &store.MCPOAuthToken{
ServerID: serverID,
TenantID: tenantID,
AccessToken: "cached-token",
TokenType: "Bearer",
ExpiresAt: futureTime(2 * time.Hour),
}
st.global[serverID.String()] = validTok
// First call — loads from store and caches.
tok1, err := r.GetValidToken(context.Background(), serverID, tenantID, "")
if err != nil {
t.Fatalf("first GetValidToken() error: %v", err)
}
if tok1 != "cached-token" {
t.Errorf("token = %q, want %q", tok1, "cached-token")
}
// Remove from store to prove second call uses cache.
delete(st.global, serverID.String())
tok2, err := r.GetValidToken(context.Background(), serverID, tenantID, "")
if err != nil {
t.Fatalf("second GetValidToken() error: %v", err)
}
if tok2 != "cached-token" {
t.Errorf("second token = %q, want %q (from cache)", tok2, "cached-token")
}
}
func TestGetValidTokenCacheMiss(t *testing.T) {
st := newMockOAuthStore()
r := NewRefresher(st, http.DefaultClient)
serverID := uuid.New()
tenantID := uuid.New()
tok := &store.MCPOAuthToken{
ServerID: serverID,
TenantID: tenantID,
AccessToken: "fresh-from-db",
TokenType: "Bearer",
ExpiresAt: futureTime(30 * time.Minute),
}
st.global[serverID.String()] = tok
got, err := r.GetValidToken(context.Background(), serverID, tenantID, "")
if err != nil {
t.Fatalf("GetValidToken() error: %v", err)
}
if got != "fresh-from-db" {
t.Errorf("token = %q, want %q", got, "fresh-from-db")
}
}
func TestGetValidTokenNoRecord(t *testing.T) {
st := newMockOAuthStore()
r := NewRefresher(st, http.DefaultClient)
_, err := r.GetValidToken(context.Background(), uuid.New(), uuid.New(), "")
if !errors.Is(err, ErrTokenExpired) {
t.Errorf("expected ErrTokenExpired, got %v", err)
}
}
func TestGetValidTokenExpiredNoRefreshToken(t *testing.T) {
st := newMockOAuthStore()
r := NewRefresher(st, http.DefaultClient)
serverID := uuid.New()
tenantID := uuid.New()
st.global[serverID.String()] = &store.MCPOAuthToken{
ServerID: serverID,
TenantID: tenantID,
AccessToken: "old-token",
TokenType: "Bearer",
ExpiresAt: pastTime(time.Hour),
RefreshToken: "", // no refresh token
}
_, err := r.GetValidToken(context.Background(), serverID, tenantID, "")
if !errors.Is(err, ErrTokenExpired) {
t.Errorf("expected ErrTokenExpired for expired token without refresh, got %v", err)
}
}
func TestGetValidTokenRefreshSuccess(t *testing.T) {
security.SetAllowLoopbackForTest(true)
defer security.SetAllowLoopbackForTest(false)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
if r.FormValue("grant_type") != "refresh_token" {
http.Error(w, "wrong grant_type", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(OAuthTokens{
AccessToken: "new-access-token",
TokenType: "Bearer",
ExpiresIn: 3600,
})
}))
defer srv.Close()
st := newMockOAuthStore()
r := NewRefresher(st, security.NewSafeClient(5*time.Second))
serverID := uuid.New()
tenantID := uuid.New()
st.global[serverID.String()] = &store.MCPOAuthToken{
ServerID: serverID,
TenantID: tenantID,
AccessToken: "expired-token",
RefreshToken: "valid-refresh",
TokenType: "Bearer",
ExpiresAt: pastTime(time.Hour),
TokenEndpoint: srv.URL,
}
got, err := r.GetValidToken(context.Background(), serverID, tenantID, "")
if err != nil {
t.Fatalf("GetValidToken() with refresh error: %v", err)
}
if got != "new-access-token" {
t.Errorf("token = %q, want %q", got, "new-access-token")
}
if st.upserts == 0 {
t.Error("expected UpsertOAuthToken to be called after refresh")
}
}
func TestGetValidTokenRefreshFails(t *testing.T) {
security.SetAllowLoopbackForTest(true)
defer security.SetAllowLoopbackForTest(false)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
}))
defer srv.Close()
st := newMockOAuthStore()
r := NewRefresher(st, security.NewSafeClient(5*time.Second))
serverID := uuid.New()
tenantID := uuid.New()
st.global[serverID.String()] = &store.MCPOAuthToken{
ServerID: serverID,
TenantID: tenantID,
AccessToken: "expired-token",
RefreshToken: "bad-refresh",
ExpiresAt: pastTime(time.Hour),
TokenEndpoint: srv.URL,
}
_, err := r.GetValidToken(context.Background(), serverID, tenantID, "")
if err == nil {
t.Fatal("expected error when refresh fails, got nil")
}
}
func TestInvalidateCacheEvictsEntry(t *testing.T) {
st := newMockOAuthStore()
r := NewRefresher(st, http.DefaultClient)
serverID := uuid.New()
tenantID := uuid.New()
tok := &store.MCPOAuthToken{
ServerID: serverID,
TenantID: tenantID,
AccessToken: "first-token",
TokenType: "Bearer",
ExpiresAt: futureTime(2 * time.Hour),
}
st.global[serverID.String()] = tok
// Load into cache.
if _, err := r.GetValidToken(context.Background(), serverID, tenantID, ""); err != nil {
t.Fatalf("GetValidToken() error: %v", err)
}
// Update store with new token.
st.global[serverID.String()] = &store.MCPOAuthToken{
ServerID: serverID,
TenantID: tenantID,
AccessToken: "updated-token",
TokenType: "Bearer",
ExpiresAt: futureTime(2 * time.Hour),
}
// Without invalidation, cache returns old token.
got, _ := r.GetValidToken(context.Background(), serverID, tenantID, "")
if got != "first-token" {
t.Errorf("before invalidation: got %q, want %q", got, "first-token")
}
// Invalidate and retry.
r.InvalidateCache(serverID, "")
got, err := r.GetValidToken(context.Background(), serverID, tenantID, "")
if err != nil {
t.Fatalf("GetValidToken() after invalidate error: %v", err)
}
if got != "updated-token" {
t.Errorf("after invalidation: got %q, want %q", got, "updated-token")
}
}
func TestGetValidTokenPerUserVsGlobal(t *testing.T) {
st := newMockOAuthStore()
r := NewRefresher(st, http.DefaultClient)
serverID := uuid.New()
tenantID := uuid.New()
userID := "user-123"
// Global token.
st.global[serverID.String()] = &store.MCPOAuthToken{
ServerID: serverID,
TenantID: tenantID,
AccessToken: "global-token",
TokenType: "Bearer",
ExpiresAt: futureTime(time.Hour),
}
// Per-user token.
st.perUser[serverID.String()+":"+userID] = &store.MCPOAuthToken{
ServerID: serverID,
TenantID: tenantID,
UserID: userID,
AccessToken: "user-token",
TokenType: "Bearer",
ExpiresAt: futureTime(time.Hour),
}
globalTok, err := r.GetValidToken(context.Background(), serverID, tenantID, "")
if err != nil {
t.Fatalf("global GetValidToken() error: %v", err)
}
userTok, err := r.GetValidToken(context.Background(), serverID, tenantID, userID)
if err != nil {
t.Fatalf("per-user GetValidToken() error: %v", err)
}
if globalTok != "global-token" {
t.Errorf("global token = %q, want %q", globalTok, "global-token")
}
if userTok != "user-token" {
t.Errorf("per-user token = %q, want %q", userTok, "user-token")
}
}