mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 12:14:17 +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.
227 lines
7.4 KiB
Go
227 lines
7.4 KiB
Go
// Package mcpoauth implements OAuth 2.1 client support for MCP servers.
|
|
// Discovery follows RFC 9728 (Protected Resource Metadata) then RFC 8414
|
|
// (Authorization Server Metadata) with OIDC fallback.
|
|
package mcpoauth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/security"
|
|
)
|
|
|
|
const discoveryCacheTTL = 5 * time.Minute
|
|
|
|
// DiscoveryResult holds the Authorization Server metadata for an MCP server.
|
|
type DiscoveryResult struct {
|
|
Issuer string
|
|
AuthorizationEndpoint string
|
|
TokenEndpoint string
|
|
RegistrationEndpoint string // DCR — optional
|
|
ResourceURI string // RFC 8707 resource indicator
|
|
ScopesSupported []string
|
|
}
|
|
|
|
type cachedDiscovery struct {
|
|
result *DiscoveryResult
|
|
fetchedAt time.Time
|
|
}
|
|
|
|
// Discoverer caches and performs OAuth AS discovery for MCP server URLs.
|
|
type Discoverer struct {
|
|
client *http.Client
|
|
mu sync.Mutex
|
|
cache map[string]*cachedDiscovery
|
|
}
|
|
|
|
func NewDiscoverer(client *http.Client) *Discoverer {
|
|
return &Discoverer{
|
|
client: client,
|
|
cache: make(map[string]*cachedDiscovery),
|
|
}
|
|
}
|
|
|
|
// Discover finds the OAuth Authorization Server metadata for an MCP server URL.
|
|
//
|
|
// Priority (per plan spec):
|
|
//
|
|
// Phase 1 — RFC 9728: fetch /.well-known/oauth-protected-resource[/{path}]
|
|
// to resolve the Authorization Server base URL.
|
|
// Phase 2 — RFC 8414 + OIDC fallback on the resolved AS base URL:
|
|
// 1. {AS}/.well-known/oauth-authorization-server
|
|
// 2. {AS}/.well-known/oauth-authorization-server/{path}
|
|
// 3. {AS}/.well-known/openid-configuration
|
|
// 4. {AS}/{path}/.well-known/openid-configuration
|
|
func (d *Discoverer) Discover(ctx context.Context, mcpServerURL string) (*DiscoveryResult, error) {
|
|
d.mu.Lock()
|
|
if c, ok := d.cache[mcpServerURL]; ok && time.Since(c.fetchedAt) < discoveryCacheTTL {
|
|
d.mu.Unlock()
|
|
return c.result, nil
|
|
}
|
|
d.mu.Unlock()
|
|
|
|
result, err := d.discover(ctx, mcpServerURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
d.mu.Lock()
|
|
d.cache[mcpServerURL] = &cachedDiscovery{result: result, fetchedAt: time.Now()}
|
|
d.mu.Unlock()
|
|
return result, nil
|
|
}
|
|
|
|
// InvalidateCache removes the cached discovery for the given MCP server URL.
|
|
func (d *Discoverer) InvalidateCache(mcpServerURL string) {
|
|
d.mu.Lock()
|
|
delete(d.cache, mcpServerURL)
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
func (d *Discoverer) discover(ctx context.Context, mcpServerURL string) (*DiscoveryResult, error) {
|
|
parsed, err := url.Parse(mcpServerURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mcpoauth: invalid MCP server URL %q: %w", mcpServerURL, err)
|
|
}
|
|
base := parsed.Scheme + "://" + parsed.Host
|
|
path := strings.TrimPrefix(parsed.Path, "/")
|
|
|
|
// Phase 1 — RFC 9728: try to discover Authorization Server via Protected Resource Metadata.
|
|
// When RFC 9728 succeeds, the returned AS base URL is authoritative — no path suffix needed.
|
|
// When RFC 9728 fails, fall back to using the MCP server base with its path.
|
|
asBase := base
|
|
asPath := path
|
|
if prm, err2 := d.fetchProtectedResourceMeta(ctx, base, path); err2 == nil && len(prm.AuthorizationServers) > 0 {
|
|
asBase = strings.TrimRight(prm.AuthorizationServers[0], "/")
|
|
asPath = "" // AS URL from RFC 9728 is complete — no path suffix
|
|
}
|
|
|
|
// Phase 2 — RFC 8414 + OIDC fallback on AS base.
|
|
return d.discoverASMeta(ctx, asBase, asPath, mcpServerURL)
|
|
}
|
|
|
|
type protectedResourceMeta struct {
|
|
Resource string `json:"resource"`
|
|
AuthorizationServers []string `json:"authorization_servers"`
|
|
}
|
|
|
|
func (d *Discoverer) fetchProtectedResourceMeta(ctx context.Context, base, path string) (*protectedResourceMeta, error) {
|
|
candidates := []string{
|
|
base + "/.well-known/oauth-protected-resource",
|
|
}
|
|
if path != "" {
|
|
candidates = append(candidates, base+"/.well-known/oauth-protected-resource/"+path)
|
|
}
|
|
for _, u := range candidates {
|
|
var meta protectedResourceMeta
|
|
if err := d.getJSON(ctx, u, &meta); err == nil && len(meta.AuthorizationServers) > 0 {
|
|
return &meta, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("mcpoauth: RFC 9728 discovery failed")
|
|
}
|
|
|
|
type asMeta struct {
|
|
Issuer string `json:"issuer"`
|
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
|
TokenEndpoint string `json:"token_endpoint"`
|
|
RegistrationEndpoint string `json:"registration_endpoint"`
|
|
ScopesSupported []string `json:"scopes_supported"`
|
|
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
|
}
|
|
|
|
func (d *Discoverer) discoverASMeta(ctx context.Context, asBase, path, mcpServerURL string) (*DiscoveryResult, error) {
|
|
candidates := []string{
|
|
asBase + "/.well-known/oauth-authorization-server",
|
|
}
|
|
if path != "" {
|
|
candidates = append(candidates,
|
|
asBase+"/.well-known/oauth-authorization-server/"+path,
|
|
)
|
|
}
|
|
candidates = append(candidates, asBase+"/.well-known/openid-configuration")
|
|
if path != "" {
|
|
candidates = append(candidates, asBase+"/"+path+"/.well-known/openid-configuration")
|
|
}
|
|
|
|
for _, u := range candidates {
|
|
var meta asMeta
|
|
if err := d.getJSON(ctx, u, &meta); err != nil {
|
|
continue
|
|
}
|
|
if meta.AuthorizationEndpoint == "" || meta.TokenEndpoint == "" {
|
|
continue
|
|
}
|
|
// RFC 8414 §3.3: the issuer in the metadata must identify the same
|
|
// Authorization Server the metadata was retrieved from. Requiring the
|
|
// issuer origin to match the fetch origin blocks an AS mix-up where a
|
|
// compromised resource server points discovery at an issuer running on
|
|
// a host it does not control. Path differences are tolerated (issuers
|
|
// like Azure AD carry a tenant path), and an absent issuer is allowed
|
|
// since some OIDC fallbacks omit it.
|
|
if meta.Issuer != "" && !sameOrigin(meta.Issuer, u) {
|
|
slog.Warn("mcpoauth.issuer_mismatch",
|
|
"metadata_url", u, "advertised_issuer", meta.Issuer)
|
|
continue
|
|
}
|
|
return &DiscoveryResult{
|
|
Issuer: meta.Issuer,
|
|
AuthorizationEndpoint: meta.AuthorizationEndpoint,
|
|
TokenEndpoint: meta.TokenEndpoint,
|
|
RegistrationEndpoint: meta.RegistrationEndpoint,
|
|
ResourceURI: mcpServerURL,
|
|
ScopesSupported: meta.ScopesSupported,
|
|
}, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("mcpoauth: no OAuth Authorization Server metadata found for %s", mcpServerURL)
|
|
}
|
|
|
|
// sameOrigin reports whether two URLs share the same scheme and host
|
|
// (case-insensitive). Path, query, and fragment are ignored.
|
|
func sameOrigin(a, b string) bool {
|
|
ua, err1 := url.Parse(a)
|
|
ub, err2 := url.Parse(b)
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
return strings.EqualFold(ua.Scheme, ub.Scheme) && strings.EqualFold(ua.Host, ub.Host)
|
|
}
|
|
|
|
func (d *Discoverer) getJSON(ctx context.Context, rawURL string, dst any) error {
|
|
// Validate URL and pin the resolved IP into ctx so the safe HTTP client
|
|
// can dial without re-resolving (prevents DNS rebinding / SSRF).
|
|
_, ip, err := security.Validate(rawURL)
|
|
if err != nil {
|
|
return fmt.Errorf("mcpoauth: SSRF validation failed for %s: %w", rawURL, err)
|
|
}
|
|
ctx = security.WithPinnedIP(ctx, ip)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := d.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("mcpoauth: HTTP %d from %s", resp.StatusCode, rawURL)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(body, dst)
|
|
}
|