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

199 lines
6.3 KiB
Go

//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"errors"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteMCPOAuthTokenStore implements store.MCPOAuthTokenStore backed by SQLite.
type SQLiteMCPOAuthTokenStore struct {
db *sql.DB
encKey string
}
func NewSQLiteMCPOAuthTokenStore(db *sql.DB, encryptionKey string) *SQLiteMCPOAuthTokenStore {
return &SQLiteMCPOAuthTokenStore{db: db, encKey: encryptionKey}
}
func (s *SQLiteMCPOAuthTokenStore) GetOAuthToken(ctx context.Context, serverID, tenantID uuid.UUID) (*store.MCPOAuthToken, error) {
return s.getToken(ctx, serverID, tenantID, "")
}
func (s *SQLiteMCPOAuthTokenStore) GetUserOAuthToken(ctx context.Context, serverID, tenantID uuid.UUID, userID string) (*store.MCPOAuthToken, error) {
return s.getToken(ctx, serverID, tenantID, userID)
}
func (s *SQLiteMCPOAuthTokenStore) getToken(ctx context.Context, serverID, tenantID uuid.UUID, userID string) (*store.MCPOAuthToken, error) {
var row store.MCPOAuthToken
var accessEnc, refreshEnc, secretEnc sql.NullString
var expiresAtStr, issuedAtStr sql.NullString
var scopes, resourceURI sql.NullString
var idStr, serverStr, tenantStr string
var query string
var args []any
if userID == "" {
query = `SELECT id, server_id, tenant_id, COALESCE(user_id,''), access_token, refresh_token,
token_type, COALESCE(scopes,''), expires_at, issued_at,
dcr_client_id, dcr_client_secret, dcr_issuer, token_endpoint,
COALESCE(resource_uri,''), created_at, updated_at
FROM mcp_oauth_tokens
WHERE server_id=? AND tenant_id=? AND user_id IS NULL`
args = []any{serverID.String(), tenantID.String()}
} else {
query = `SELECT id, server_id, tenant_id, COALESCE(user_id,''), access_token, refresh_token,
token_type, COALESCE(scopes,''), expires_at, issued_at,
dcr_client_id, dcr_client_secret, dcr_issuer, token_endpoint,
COALESCE(resource_uri,''), created_at, updated_at
FROM mcp_oauth_tokens
WHERE server_id=? AND tenant_id=? AND user_id=?`
args = []any{serverID.String(), tenantID.String(), userID}
}
var createdStr, updatedStr string
err := s.db.QueryRowContext(ctx, query, args...).Scan(
&idStr, &serverStr, &tenantStr, &row.UserID,
&accessEnc, &refreshEnc,
&row.TokenType, &scopes, &expiresAtStr, &issuedAtStr,
&row.DCRClientID, &secretEnc, &row.DCRIssuer, &row.TokenEndpoint,
&resourceURI, &createdStr, &updatedStr,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
row.ID, _ = uuid.Parse(idStr)
row.ServerID, _ = uuid.Parse(serverStr)
row.TenantID, _ = uuid.Parse(tenantStr)
if scopes.Valid {
row.Scopes = scopes.String
}
if resourceURI.Valid {
row.ResourceURI = resourceURI.String
}
if expiresAtStr.Valid && expiresAtStr.String != "" {
if t, err2 := time.Parse(time.RFC3339Nano, expiresAtStr.String); err2 == nil {
row.ExpiresAt = &t
}
}
if issuedAtStr.Valid && issuedAtStr.String != "" {
if t, err2 := time.Parse(time.RFC3339Nano, issuedAtStr.String); err2 == nil {
row.IssuedAt = &t
}
}
row.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdStr)
row.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedStr)
if accessEnc.Valid && accessEnc.String != "" {
if dec, err2 := crypto.Decrypt(accessEnc.String, s.encKey); err2 == nil {
row.AccessToken = dec
} else {
slog.Warn("mcpoauth: decrypt access_token failed", "server_id", serverID)
}
}
if refreshEnc.Valid && refreshEnc.String != "" {
if dec, err2 := crypto.Decrypt(refreshEnc.String, s.encKey); err2 == nil {
row.RefreshToken = dec
}
}
if secretEnc.Valid && secretEnc.String != "" {
if dec, err2 := crypto.Decrypt(secretEnc.String, s.encKey); err2 == nil {
row.DCRClientSecret = dec
}
}
return &row, nil
}
func (s *SQLiteMCPOAuthTokenStore) UpsertOAuthToken(ctx context.Context, tok *store.MCPOAuthToken) error {
if tok.ID == uuid.Nil {
tok.ID = uuid.New()
}
accessEnc, err := crypto.Encrypt(tok.AccessToken, s.encKey)
if err != nil {
return err
}
var refreshEnc sql.NullString
if tok.RefreshToken != "" {
enc, err2 := crypto.Encrypt(tok.RefreshToken, s.encKey)
if err2 != nil {
return err2
}
refreshEnc = sql.NullString{String: enc, Valid: true}
}
var secretEnc sql.NullString
if tok.DCRClientSecret != "" {
enc, err2 := crypto.Encrypt(tok.DCRClientSecret, s.encKey)
if err2 != nil {
return err2
}
secretEnc = sql.NullString{String: enc, Valid: true}
}
var userIDVal any = nil
if tok.UserID != "" {
userIDVal = tok.UserID
}
var expiresStr, issuedStr any
if tok.ExpiresAt != nil {
expiresStr = tok.ExpiresAt.UTC().Format(time.RFC3339Nano)
}
if tok.IssuedAt != nil {
issuedStr = tok.IssuedAt.UTC().Format(time.RFC3339Nano)
}
now := time.Now().UTC().Format(time.RFC3339Nano)
_, err = s.db.ExecContext(ctx, `
INSERT OR REPLACE INTO mcp_oauth_tokens
(id, server_id, tenant_id, user_id, access_token, refresh_token, token_type,
scopes, expires_at, issued_at, dcr_client_id, dcr_client_secret, dcr_issuer,
token_endpoint, resource_uri, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
tok.ID.String(), tok.ServerID.String(), tok.TenantID.String(), userIDVal,
accessEnc, refreshEnc, tok.TokenType,
tok.Scopes, expiresStr, issuedStr,
tok.DCRClientID, secretEnc, tok.DCRIssuer,
tok.TokenEndpoint, tok.ResourceURI,
now, now,
)
return err
}
func (s *SQLiteMCPOAuthTokenStore) DeleteOAuthToken(ctx context.Context, serverID, tenantID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM mcp_oauth_tokens WHERE server_id=? AND tenant_id=? AND user_id IS NULL`,
serverID.String(), tenantID.String(),
)
return err
}
func (s *SQLiteMCPOAuthTokenStore) DeleteUserOAuthToken(ctx context.Context, serverID, tenantID uuid.UUID, userID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM mcp_oauth_tokens WHERE server_id=? AND tenant_id=? AND user_id=?`,
serverID.String(), tenantID.String(), userID,
)
return err
}
func (s *SQLiteMCPOAuthTokenStore) DeleteServerOAuthTokens(ctx context.Context, serverID, tenantID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM mcp_oauth_tokens WHERE server_id=? AND tenant_id=?`,
serverID.String(), tenantID.String(),
)
return err
}