mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 10:14:51 +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.
199 lines
6.3 KiB
Go
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
|
|
}
|