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

202 lines
6.3 KiB
Go

package mcpoauth
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"golang.org/x/sync/singleflight"
)
const tokenRefreshBuffer = 60 * time.Second
// ErrTokenExpired is returned when the token is expired and cannot be refreshed.
var ErrTokenExpired = errors.New("mcpoauth: token expired and no refresh token available")
type cachedToken struct {
accessToken string
expiresAt time.Time
}
// Refresher provides valid OAuth access tokens for MCP servers, handling
// in-memory caching and transparent refresh using stored refresh tokens.
type Refresher struct {
store store.MCPOAuthTokenStore
client *http.Client
mu sync.Mutex
cache map[string]*cachedToken // key: serverID+":"+userID
// sf coalesces concurrent DB-load + refresh for the same slot so a rotating
// refresh token (RFC 9700) is never consumed twice by parallel agent turns.
sf singleflight.Group
}
// NewRefresher builds a Refresher. Token decryption happens in the store layer,
// so the Refresher itself never touches the encryption key.
func NewRefresher(s store.MCPOAuthTokenStore, client *http.Client) *Refresher {
return &Refresher{
store: s,
client: client,
cache: make(map[string]*cachedToken),
}
}
// GetValidToken returns a valid access token for the given server.
// userID="" uses the global (tenant-level) token; non-empty uses the per-user token.
func (r *Refresher) GetValidToken(ctx context.Context, serverID, tenantID uuid.UUID, userID string) (string, error) {
cacheKey := serverID.String() + ":" + userID
if tok, ok := r.cachedValid(cacheKey); ok {
return tok, nil
}
// Coalesce concurrent DB-load + refresh for the same slot. Without this,
// two parallel agent turns hitting an expired token both POST a refresh and
// the second one replays an already-rotated refresh token, killing the slot.
v, err, _ := r.sf.Do(cacheKey, func() (any, error) {
// Double-check: a coalesced caller ahead of us may have just refreshed.
if tok, ok := r.cachedValid(cacheKey); ok {
return tok, nil
}
return r.loadAndRefresh(ctx, serverID, tenantID, userID, cacheKey)
})
if err != nil {
return "", err
}
return v.(string), nil
}
// cachedValid returns the cached access token when it is still inside the
// refresh buffer, false otherwise.
func (r *Refresher) cachedValid(cacheKey string) (string, bool) {
r.mu.Lock()
defer r.mu.Unlock()
if c, ok := r.cache[cacheKey]; ok && time.Now().Add(tokenRefreshBuffer).Before(c.expiresAt) {
return c.accessToken, true
}
return "", false
}
// loadAndRefresh loads the token from the store and refreshes it if expired.
// Runs under the single-flight lock for its cache key.
func (r *Refresher) loadAndRefresh(ctx context.Context, serverID, tenantID uuid.UUID, userID, cacheKey string) (string, error) {
tok, err := r.loadToken(ctx, serverID, tenantID, userID)
if err != nil {
slog.Warn("mcpoauth: load token failed", "server_id", serverID, "tenant_id", tenantID, "user_id", userID, "error", err)
return "", err
}
if tok == nil {
slog.Debug("mcpoauth: no token in db", "server_id", serverID, "tenant_id", tenantID, "user_id", userID)
return "", ErrTokenExpired
}
// No expiry advertised → treat as non-expiring. RFC 6749 §5.1 makes
// expires_in OPTIONAL, so a token without an expiry (common for
// client_credentials and some long-lived grants) is still valid and must
// not be forced through the refresh/ErrTokenExpired path.
if tok.ExpiresAt == nil {
r.setCache(cacheKey, tok.AccessToken, nil)
return tok.AccessToken, nil
}
// Valid in DB?
if time.Now().Add(tokenRefreshBuffer).Before(*tok.ExpiresAt) {
r.setCache(cacheKey, tok.AccessToken, tok.ExpiresAt)
return tok.AccessToken, nil
}
// Expired — try refresh.
if tok.RefreshToken == "" {
return "", ErrTokenExpired
}
newTokens, err := r.refresh(ctx, tok)
if err != nil {
return "", fmt.Errorf("mcpoauth: token refresh failed: %w", err)
}
// Persist refreshed tokens.
now := time.Now()
var expiresAt *time.Time
if newTokens.ExpiresIn > 0 {
t := now.Add(time.Duration(newTokens.ExpiresIn) * time.Second)
expiresAt = &t
}
updated := *tok
updated.AccessToken = newTokens.AccessToken
if newTokens.RefreshToken != "" {
updated.RefreshToken = newTokens.RefreshToken
}
updated.ExpiresAt = expiresAt
updated.IssuedAt = &now
if err := r.store.UpsertOAuthToken(ctx, &updated); err != nil {
return "", fmt.Errorf("mcpoauth: save refreshed token: %w", err)
}
r.setCache(cacheKey, newTokens.AccessToken, expiresAt)
return newTokens.AccessToken, nil
}
// InvalidateCache evicts the in-memory cached token for the given server/user.
func (r *Refresher) InvalidateCache(serverID uuid.UUID, userID string) {
key := serverID.String() + ":" + userID
r.mu.Lock()
delete(r.cache, key)
r.mu.Unlock()
}
// InvalidateServer evicts every cached token for the given server (global +
// all per-user slots). Cache keys are "<serverID>:<userID>", so all entries
// sharing the server prefix are dropped. Called when the server URL or OAuth
// config changes and the stored tokens have been purged.
func (r *Refresher) InvalidateServer(serverID uuid.UUID) {
prefix := serverID.String() + ":"
r.mu.Lock()
for key := range r.cache {
if strings.HasPrefix(key, prefix) {
delete(r.cache, key)
}
}
r.mu.Unlock()
}
func (r *Refresher) loadToken(ctx context.Context, serverID, tenantID uuid.UUID, userID string) (*store.MCPOAuthToken, error) {
if userID == "" {
return r.store.GetOAuthToken(ctx, serverID, tenantID)
}
return r.store.GetUserOAuthToken(ctx, serverID, tenantID, userID)
}
func (r *Refresher) refresh(ctx context.Context, tok *store.MCPOAuthToken) (*OAuthTokens, error) {
params := url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {tok.RefreshToken},
"client_id": {tok.DCRClientID},
}
if tok.DCRClientSecret != "" {
params.Set("client_secret", tok.DCRClientSecret)
}
if tok.ResourceURI != "" {
params.Set("resource", tok.ResourceURI)
}
return postTokenRequest(ctx, r.client, tok.TokenEndpoint, params)
}
func (r *Refresher) setCache(key, accessToken string, expiresAt *time.Time) {
c := &cachedToken{accessToken: accessToken}
if expiresAt != nil {
c.expiresAt = *expiresAt
} else {
c.expiresAt = time.Now().Add(time.Hour)
}
r.mu.Lock()
r.cache[key] = c
r.mu.Unlock()
}