mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 16:15:24 +00:00
737 lines
26 KiB
Go
737 lines
26 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
mcpoauth "github.com/nextlevelbuilder/goclaw/internal/mcp/oauth"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/security"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// MCPOAuthHandler exposes OAuth endpoints for MCP server authentication.
|
|
type MCPOAuthHandler struct {
|
|
mcpStore store.MCPServerStore
|
|
oauthStore store.MCPOAuthTokenStore
|
|
discoverer *mcpoauth.Discoverer
|
|
flowMgr *mcpoauth.FlowManager
|
|
refresher *mcpoauth.Refresher
|
|
eventBus bus.EventPublisher
|
|
publicURL string // e.g. "https://goclaw.example.com"
|
|
port int
|
|
evictor MCPPoolEvictor
|
|
// tenantStore gates tenant-scoped writes (token mint/revoke/admin status) with a
|
|
// tenant-admin membership check — RoleAdmin alone is not a tenant check.
|
|
tenantStore store.TenantStore
|
|
}
|
|
|
|
// MCPOAuthHandlerDeps contains all dependencies for the OAuth handler.
|
|
type MCPOAuthHandlerDeps struct {
|
|
MCPStore store.MCPServerStore
|
|
OAuthStore store.MCPOAuthTokenStore
|
|
Discoverer *mcpoauth.Discoverer
|
|
FlowMgr *mcpoauth.FlowManager
|
|
Refresher *mcpoauth.Refresher
|
|
EventBus bus.EventPublisher
|
|
PublicURL string
|
|
Port int
|
|
Evictor MCPPoolEvictor
|
|
TenantStore store.TenantStore
|
|
}
|
|
|
|
// NewMCPOAuthHandler creates an MCPOAuthHandler.
|
|
func NewMCPOAuthHandler(deps MCPOAuthHandlerDeps) *MCPOAuthHandler {
|
|
return &MCPOAuthHandler{
|
|
mcpStore: deps.MCPStore,
|
|
oauthStore: deps.OAuthStore,
|
|
discoverer: deps.Discoverer,
|
|
flowMgr: deps.FlowMgr,
|
|
refresher: deps.Refresher,
|
|
eventBus: deps.EventBus,
|
|
publicURL: deps.PublicURL,
|
|
port: deps.Port,
|
|
evictor: deps.Evictor,
|
|
tenantStore: deps.TenantStore,
|
|
}
|
|
}
|
|
|
|
// SetEvictor sets the MCP pool evictor (called after construction when pool is available).
|
|
func (h *MCPOAuthHandler) SetEvictor(e MCPPoolEvictor) { h.evictor = e }
|
|
|
|
// RegisterRoutes registers all MCP OAuth routes on the given mux.
|
|
func (h *MCPOAuthHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
// Authorization is scope-based, mirroring the per-user MCP credentials flow
|
|
// (resolveTargetUserID in mcp_user_credentials.go): start/status/revoke are
|
|
// open to any authenticated user, and each handler then calls authorizeOAuthScope —
|
|
// a user may manage their OWN per-user token (self-service, matching the
|
|
// MCPUserCredentialsDialog "Authorize" button), while the global/server token
|
|
// (user_id="") and other users' tokens require tenant-admin. RoleAdmin alone is
|
|
// not a tenant check; SQL scoping (WHERE tenant_id=$N) is the final layer.
|
|
// discover only previews AS metadata for a server config — keep it admin-only.
|
|
mux.HandleFunc("POST /v1/mcp/oauth/start", requireAuth("", h.handleStart))
|
|
mux.HandleFunc("GET /v1/mcp/oauth/callback", h.handleCallback)
|
|
mux.HandleFunc("GET /v1/mcp/oauth/status/{id}", requireAuth("", h.handleStatus))
|
|
mux.HandleFunc("DELETE /v1/mcp/oauth/token/{id}", requireAuth("", h.handleRevoke))
|
|
mux.HandleFunc("POST /v1/mcp/oauth/discover/{id}", requireAuth(permissions.RoleAdmin, h.handleDiscover))
|
|
}
|
|
|
|
// callbackURL derives the OAuth redirect URI from the incoming request.
|
|
//
|
|
// Priority:
|
|
// 1. Config public_url — explicit admin override (wins when set)
|
|
// 2. X-Forwarded-Proto + X-Forwarded-Host — nginx / reverse proxy
|
|
// 3. r.Host — the backend's actual host:port (correct for local dev even when
|
|
// the frontend runs on a different port than the backend)
|
|
// 4. localhost:port — final fallback
|
|
//
|
|
// Using r.Host instead of the Origin header is intentional: Origin carries the
|
|
// *frontend* origin (e.g. Vite dev server on :5173), which differs from the
|
|
// backend address that OAuth providers must redirect back to.
|
|
func (h *MCPOAuthHandler) callbackURL(r *http.Request) string {
|
|
const path = "/v1/mcp/oauth/callback"
|
|
|
|
// 1. Explicit config override (highest priority).
|
|
if h.publicURL != "" {
|
|
return strings.TrimRight(h.publicURL, "/") + path
|
|
}
|
|
|
|
// 2. Nginx / reverse-proxy forwarded headers.
|
|
if fwdHost := r.Header.Get("X-Forwarded-Host"); fwdHost != "" {
|
|
proto := r.Header.Get("X-Forwarded-Proto")
|
|
if proto == "" {
|
|
proto = "https"
|
|
}
|
|
return proto + "://" + strings.TrimRight(fwdHost, "/") + path
|
|
}
|
|
|
|
// 3. Backend's own host:port from the request (works for localhost:18790 and
|
|
// any custom host — correct even in split-port dev setups).
|
|
if host := r.Host; host != "" {
|
|
scheme := "http"
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
return scheme + "://" + host + path
|
|
}
|
|
|
|
// 4. Fallback: localhost + configured port.
|
|
port := h.port
|
|
if port == 0 {
|
|
port = 8080
|
|
}
|
|
return fmt.Sprintf("http://localhost:%d%s", port, path)
|
|
}
|
|
|
|
// --- POST /v1/mcp/oauth/start ---
|
|
|
|
type startOAuthReq struct {
|
|
ServerID string `json:"server_id"`
|
|
MCPURL string `json:"mcp_url"`
|
|
UserID string `json:"user_id,omitempty"` // empty = global token
|
|
}
|
|
|
|
type startOAuthResp struct {
|
|
AuthURL string `json:"auth_url"`
|
|
State string `json:"state"`
|
|
ClientID string `json:"client_id"`
|
|
Issuer string `json:"issuer"`
|
|
Completed bool `json:"completed,omitempty"` // true for client_credentials — token already minted, no redirect needed
|
|
}
|
|
|
|
// dropboxOfflineAuthParam returns the authorization-request parameter Dropbox
|
|
// requires to issue a refresh token. Dropbox's default is an online-only token
|
|
// (expires ~4h, cannot be refreshed), so every Dropbox authorize URL must carry
|
|
// token_access_type=offline for goclaw's auto-refresh to work. Returns nil for
|
|
// every other authorization server — unknown params could break stricter ASes.
|
|
func dropboxOfflineAuthParam(authEndpoint string) url.Values {
|
|
u, err := url.Parse(authEndpoint)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
host := u.Hostname()
|
|
if host == "www.dropbox.com" || strings.HasSuffix(host, ".dropbox.com") {
|
|
return url.Values{"token_access_type": {"offline"}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// authorizeOAuthScope enforces who may operate on a given OAuth token scope.
|
|
// A per-user token (non-empty targetUserID) may be managed by its owner without
|
|
// admin rights — self-service, matching the per-user MCP credentials flow and the
|
|
// MCPUserCredentialsDialog "Authorize" button. The global/server token
|
|
// (targetUserID="") and another user's per-user token require tenant-admin, so a
|
|
// RoleAdmin who is not a tenant admin cannot mint/revoke tenant-scoped tokens.
|
|
func (h *MCPOAuthHandler) authorizeOAuthScope(w http.ResponseWriter, r *http.Request, targetUserID string) bool {
|
|
// Self-service: a caller may manage their own per-user token. Only applies to a
|
|
// non-empty target (the global/server token is shared, never "self").
|
|
if targetUserID != "" {
|
|
if callerID := store.UserIDFromContext(r.Context()); callerID != "" && targetUserID == callerID {
|
|
return true
|
|
}
|
|
}
|
|
// Global token, or a per-user token on behalf of another user → tenant-admin.
|
|
return requireTenantAdmin(w, r, h.tenantStore)
|
|
}
|
|
|
|
func (h *MCPOAuthHandler) handleStart(w http.ResponseWriter, r *http.Request) {
|
|
var req startOAuthReq
|
|
if !bindJSON(w, r, store.LocaleFromContext(r.Context()), &req) {
|
|
return
|
|
}
|
|
if !h.authorizeOAuthScope(w, r, req.UserID) {
|
|
return
|
|
}
|
|
if req.ServerID == "" || req.MCPURL == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "server_id and mcp_url are required"})
|
|
return
|
|
}
|
|
|
|
serverID, err := uuid.Parse(req.ServerID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server_id"})
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
|
|
// Verify server belongs to tenant.
|
|
srv, err := h.mcpStore.GetServer(ctx, serverID)
|
|
if err != nil || srv == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "MCP server not found"})
|
|
return
|
|
}
|
|
|
|
// Parse server OAuth settings up front — they decide whether to auto-discover the
|
|
// AS + dynamically register (use_dcr) or to use operator-supplied endpoints.
|
|
// use_dcr is a pointer so a legacy/absent value (nil) keeps the discover+DCR path.
|
|
var oauthSettings struct {
|
|
OAuth struct {
|
|
UseDCR *bool `json:"use_dcr"`
|
|
AuthEndpoint string `json:"auth_endpoint"`
|
|
TokenEndpoint string `json:"token_endpoint"`
|
|
ClientID string `json:"client_id"`
|
|
ClientSecret string `json:"client_secret"`
|
|
Scope string `json:"scope"`
|
|
GrantType string `json:"grant_type"`
|
|
} `json:"oauth"`
|
|
}
|
|
if len(srv.Settings) > 0 {
|
|
_ = json.Unmarshal(srv.Settings, &oauthSettings)
|
|
}
|
|
|
|
safeClient := security.NewSafeClient(15 * time.Second)
|
|
callbackURI := h.callbackURL(r)
|
|
|
|
var disc *mcpoauth.DiscoveryResult
|
|
if oauthSettings.OAuth.UseDCR != nil && !*oauthSettings.OAuth.UseDCR {
|
|
// Manual mode: the operator disabled DCR and supplied the AS endpoints
|
|
// directly. We do NOT auto-discover or dynamically register. The endpoints
|
|
// are SSRF-validated because the token endpoint is dialed server-side and the
|
|
// authorization endpoint is handed to the browser. A manual client_id is
|
|
// required (enforced by the no-client_id guard below, since no DCR runs).
|
|
ae := strings.TrimSpace(oauthSettings.OAuth.AuthEndpoint)
|
|
te := strings.TrimSpace(oauthSettings.OAuth.TokenEndpoint)
|
|
// The token endpoint is always required (it is dialed for every grant).
|
|
// The authorization endpoint is only needed for browser/auth-code grants —
|
|
// client_credentials has no authorization redirect, matching the UI which
|
|
// hides the authorization URL field for that grant.
|
|
if te == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "manual OAuth (use_dcr=false) requires token_endpoint"})
|
|
return
|
|
}
|
|
if oauthSettings.OAuth.GrantType != "client_credentials" && ae == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "manual OAuth (use_dcr=false) requires auth_endpoint for this grant type"})
|
|
return
|
|
}
|
|
// SSRF-validate the token endpoint (always dialed) and the authorization
|
|
// endpoint when present (handed to the browser).
|
|
if _, _, verr := security.Validate(te); verr != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid token_endpoint: " + verr.Error()})
|
|
return
|
|
}
|
|
if ae != "" {
|
|
if _, _, verr := security.Validate(ae); verr != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid auth_endpoint: " + verr.Error()})
|
|
return
|
|
}
|
|
}
|
|
disc = &mcpoauth.DiscoveryResult{AuthorizationEndpoint: ae, TokenEndpoint: te}
|
|
} else {
|
|
// Discovery path. Discovery and the resulting resource indicator must bind to
|
|
// the server's own registered URL, not a client-supplied one. Trusting
|
|
// req.MCPURL would let the caller mint a token whose resource_uri / AS differ
|
|
// from the server the agent actually connects to. Fall back to req.MCPURL only
|
|
// when the server has no stored URL (legacy rows).
|
|
mcpURL := srv.URL
|
|
if mcpURL == "" {
|
|
mcpURL = req.MCPURL
|
|
}
|
|
// Reuse the shared, cache-backed discoverer when wired (5-min metadata cache);
|
|
// fall back to a request-local one only when none was injected (e.g. tests).
|
|
discoverer := h.discoverer
|
|
if discoverer == nil {
|
|
discoverer = mcpoauth.NewDiscoverer(safeClient)
|
|
}
|
|
var derr error
|
|
disc, derr = discoverer.Discover(ctx, mcpURL)
|
|
if derr != nil {
|
|
slog.Warn("mcpoauth.discover_failed", "server_id", serverID, "url", mcpURL, "error", derr)
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "OAuth discovery failed: " + derr.Error()})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Determine client credentials — priority:
|
|
// 1. Manual client_id in server settings (DCR OFF): use as-is.
|
|
// 2. DCR: always register fresh — avoids reusing stale credentials when MCP URL changes.
|
|
// 3. Fallback: existing token credentials (legacy / no-DCR servers without manual config).
|
|
clientID := oauthSettings.OAuth.ClientID
|
|
clientSecret := oauthSettings.OAuth.ClientSecret
|
|
|
|
if clientID == "" {
|
|
if disc.RegistrationEndpoint != "" {
|
|
dcrResp, err2 := mcpoauth.RegisterClient(ctx, safeClient, disc.RegistrationEndpoint, callbackURI)
|
|
if err2 != nil {
|
|
slog.Warn("mcpoauth.dcr_failed", "server_id", serverID, "error", err2)
|
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "Dynamic Client Registration failed: " + err2.Error()})
|
|
return
|
|
}
|
|
clientID = dcrResp.ClientID
|
|
clientSecret = dcrResp.ClientSecret
|
|
} else {
|
|
// No DCR endpoint — fall back to existing token's credentials if available.
|
|
var existingTok *store.MCPOAuthToken
|
|
if req.UserID == "" {
|
|
existingTok, _ = h.oauthStore.GetOAuthToken(ctx, serverID, tenantID)
|
|
} else {
|
|
existingTok, _ = h.oauthStore.GetUserOAuthToken(ctx, serverID, tenantID, req.UserID)
|
|
}
|
|
if existingTok != nil {
|
|
clientID = existingTok.DCRClientID
|
|
clientSecret = existingTok.DCRClientSecret
|
|
}
|
|
}
|
|
}
|
|
|
|
if clientID == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no client_id: DCR not supported and no manual credentials configured"})
|
|
return
|
|
}
|
|
|
|
// Scope: use manual setting, fall back to AS-advertised scopes when DCR is used.
|
|
scopes := oauthSettings.OAuth.Scope
|
|
if scopes == "" && len(disc.ScopesSupported) > 0 {
|
|
scopes = strings.Join(disc.ScopesSupported, " ")
|
|
}
|
|
|
|
// client_credentials is a non-interactive grant: there is no browser redirect.
|
|
// Obtain the token server-side now and persist it, returning Completed=true so
|
|
// the UI skips the popup. Routing it through StartFlow (authorization-code) would
|
|
// produce a bogus response_type=code authorization URL.
|
|
if oauthSettings.OAuth.GrantType == "client_credentials" {
|
|
if clientSecret == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "client_credentials grant requires a client_secret"})
|
|
return
|
|
}
|
|
tokens, ccErr := h.flowMgr.ClientCredentials(ctx, disc.TokenEndpoint, clientID, clientSecret, scopes, disc.ResourceURI)
|
|
if ccErr != nil {
|
|
slog.Warn("mcpoauth.client_credentials_failed", "server_id", serverID, "error", ccErr)
|
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "client credentials grant failed: " + ccErr.Error()})
|
|
return
|
|
}
|
|
now := time.Now()
|
|
var expiresAt *time.Time
|
|
if tokens.ExpiresIn > 0 {
|
|
t := now.Add(time.Duration(tokens.ExpiresIn) * time.Second)
|
|
expiresAt = &t
|
|
}
|
|
tok := &store.MCPOAuthToken{
|
|
ServerID: serverID,
|
|
TenantID: tenantID,
|
|
UserID: req.UserID,
|
|
AccessToken: tokens.AccessToken,
|
|
RefreshToken: tokens.RefreshToken,
|
|
TokenType: tokens.TokenType,
|
|
Scopes: tokens.Scope,
|
|
ExpiresAt: expiresAt,
|
|
IssuedAt: &now,
|
|
DCRClientID: clientID,
|
|
DCRClientSecret: clientSecret,
|
|
DCRIssuer: disc.Issuer,
|
|
TokenEndpoint: disc.TokenEndpoint,
|
|
ResourceURI: disc.ResourceURI,
|
|
}
|
|
if err := h.activateToken(ctx, tok, srv.Name); err != nil {
|
|
slog.Error("mcpoauth.save_token", "error", err, "server_id", serverID)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save token"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, startOAuthResp{Completed: true, ClientID: clientID, Issuer: disc.Issuer})
|
|
return
|
|
}
|
|
|
|
authURL, state, err := h.flowMgr.StartFlow(ctx, mcpoauth.StartFlowParams{
|
|
ServerID: serverID,
|
|
TenantID: tenantID,
|
|
UserID: req.UserID,
|
|
InitiatingUserID: store.UserIDFromContext(ctx),
|
|
DiscoveryResult: disc,
|
|
ClientID: clientID,
|
|
ClientSecret: clientSecret,
|
|
RedirectURI: callbackURI,
|
|
Scopes: scopes,
|
|
GrantType: oauthSettings.OAuth.GrantType,
|
|
ExtraAuthParams: dropboxOfflineAuthParam(disc.AuthorizationEndpoint),
|
|
})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, startOAuthResp{
|
|
AuthURL: authURL,
|
|
State: state,
|
|
ClientID: clientID,
|
|
Issuer: disc.Issuer,
|
|
})
|
|
}
|
|
|
|
// --- GET /v1/mcp/oauth/callback ---
|
|
|
|
func (h *MCPOAuthHandler) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|
code := r.URL.Query().Get("code")
|
|
state := r.URL.Query().Get("state")
|
|
oauthErr := r.URL.Query().Get("error")
|
|
oauthErrDesc := r.URL.Query().Get("error_description")
|
|
|
|
locale := extractLocale(r)
|
|
|
|
if oauthErr != "" {
|
|
h.writeCallbackHTML(w, http.StatusOK, locale, false, oauthErrDesc)
|
|
return
|
|
}
|
|
if code != "" && state == "" {
|
|
// An auth code with no state is a malformed / CSRF-risk callback (state is
|
|
// the CSRF token), so reject with 400. A bare hit with neither code nor state
|
|
// (e.g. the user opening the URL directly) falls through to the 200 HTML page.
|
|
h.writeCallbackHTML(w, http.StatusBadRequest, locale, false, "missing state")
|
|
return
|
|
}
|
|
if code == "" || state == "" {
|
|
h.writeCallbackHTML(w, http.StatusOK, locale, false, "missing code or state")
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
tokens, flow, err := h.flowMgr.ExchangeCode(ctx, state, code)
|
|
if err != nil {
|
|
slog.Warn("mcpoauth.exchange_failed", "error", err)
|
|
h.writeCallbackHTML(w, http.StatusOK, locale, false, err.Error())
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
var expiresAt *time.Time
|
|
if tokens.ExpiresIn > 0 {
|
|
t := now.Add(time.Duration(tokens.ExpiresIn) * time.Second)
|
|
expiresAt = &t
|
|
}
|
|
|
|
tok := &store.MCPOAuthToken{
|
|
ServerID: flow.ServerID,
|
|
TenantID: flow.TenantID,
|
|
UserID: flow.UserID,
|
|
AccessToken: tokens.AccessToken,
|
|
RefreshToken: tokens.RefreshToken,
|
|
TokenType: tokens.TokenType,
|
|
Scopes: tokens.Scope,
|
|
ExpiresAt: expiresAt,
|
|
IssuedAt: &now,
|
|
DCRClientID: flow.ClientID,
|
|
DCRClientSecret: flow.ClientSecret,
|
|
DCRIssuer: flow.Issuer,
|
|
TokenEndpoint: flow.TokenEndpoint,
|
|
ResourceURI: flow.ResourceURI,
|
|
}
|
|
srv, _ := h.mcpStore.GetServer(ctx, flow.ServerID)
|
|
serverName := ""
|
|
if srv != nil {
|
|
serverName = srv.Name
|
|
}
|
|
if err := h.activateToken(ctx, tok, serverName); err != nil {
|
|
slog.Error("mcpoauth.save_token", "error", err, "server_id", flow.ServerID)
|
|
h.publishOAuthComplete(flow, "error", "failed to save token")
|
|
h.writeCallbackHTML(w, http.StatusOK, locale, false, "failed to save token")
|
|
return
|
|
}
|
|
|
|
// Notify the initiating WS client that OAuth completed successfully.
|
|
h.publishOAuthComplete(flow, "success", "")
|
|
h.writeCallbackHTML(w, http.StatusOK, locale, true, "")
|
|
}
|
|
|
|
// activateToken persists a freshly obtained OAuth token and makes it live:
|
|
// it upserts the row, evicts the refresher's in-memory cache, and drops the
|
|
// pooled MCP connection (shared + per-user) so the next request reconnects
|
|
// with the new Bearer token. Shared by the authorization-code callback and
|
|
// the client_credentials path in handleStart.
|
|
func (h *MCPOAuthHandler) activateToken(ctx context.Context, tok *store.MCPOAuthToken, serverName string) error {
|
|
if err := h.oauthStore.UpsertOAuthToken(ctx, tok); err != nil {
|
|
return err
|
|
}
|
|
if h.refresher != nil {
|
|
h.refresher.InvalidateCache(tok.ServerID, tok.UserID)
|
|
}
|
|
if h.evictor != nil && serverName != "" {
|
|
h.evictor.EvictServer(tok.TenantID, serverName)
|
|
}
|
|
// Broadcast an MCP cache-invalidate so all per-user pool connections are
|
|
// evicted and agent Loop caches reload — picks up the new token for both the
|
|
// shared (global) and per-user paths on the next request.
|
|
h.emitMCPCacheInvalidate()
|
|
return nil
|
|
}
|
|
|
|
// emitMCPCacheInvalidate broadcasts an MCP cache-invalidate event. The subscriber
|
|
// (cmd/gateway_managed.go) responds with agentRouter.InvalidateAll() +
|
|
// mcpPool.EvictAllUsers(), so freshly authorized/revoked OAuth tokens take effect
|
|
// immediately instead of waiting for idle TTL. Mirrors MCPHandler.emitCacheInvalidate.
|
|
func (h *MCPOAuthHandler) emitMCPCacheInvalidate() {
|
|
if h.eventBus == nil {
|
|
return
|
|
}
|
|
h.eventBus.Broadcast(bus.Event{
|
|
Name: protocol.EventCacheInvalidate,
|
|
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindMCP},
|
|
})
|
|
}
|
|
|
|
func (h *MCPOAuthHandler) writeCallbackHTML(w http.ResponseWriter, statusCode int, locale string, success bool, errMsg string) {
|
|
status := "success"
|
|
if !success {
|
|
status = "error"
|
|
}
|
|
|
|
var bodyMsg string
|
|
if success {
|
|
bodyMsg = i18n.T(locale, i18n.MsgOAuthCallbackSuccess)
|
|
} else {
|
|
bodyMsg = i18n.T(locale, i18n.MsgOAuthCallbackFailed)
|
|
}
|
|
|
|
// Build the postMessage payload with json.Marshal. errMsg is attacker-controlled
|
|
// (reflected from the OAuth provider's `error_description` query param on this
|
|
// unauthenticated callback endpoint), so it must never be concatenated into the
|
|
// page. encoding/json escapes <, >, & to </>/& by default, which
|
|
// neutralises </script> breakout attempts inside the <script> block (prevents
|
|
// reflected XSS). bodyMsg comes from the trusted i18n catalog but is HTML-escaped
|
|
// for defence in depth before landing in the <p> element.
|
|
payload := struct {
|
|
Type string `json:"type"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error,omitempty"`
|
|
}{Type: "mcp-oauth-complete", Status: status, Error: errMsg}
|
|
msgJSON, err := json.Marshal(payload)
|
|
if err != nil {
|
|
msgJSON = []byte(`{"type":"mcp-oauth-complete","status":"error"}`)
|
|
}
|
|
|
|
htmlPage := fmt.Sprintf(`<!DOCTYPE html><html><body>
|
|
<script>
|
|
(function(){
|
|
var msg = %s;
|
|
try {
|
|
var bc = new BroadcastChannel('mcp-oauth');
|
|
bc.postMessage(msg);
|
|
bc.close();
|
|
} catch(e) {}
|
|
if(window.opener){window.opener.postMessage(msg,'*');}
|
|
setTimeout(function(){window.close();},1000);
|
|
})();
|
|
</script>
|
|
<p>%s</p>
|
|
</body></html>`, msgJSON, html.EscapeString(bodyMsg))
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(statusCode)
|
|
_, _ = fmt.Fprint(w, htmlPage)
|
|
}
|
|
|
|
// publishOAuthComplete broadcasts an mcp.oauth_complete WS event to the initiating client.
|
|
// Only called when we have a resolved pendingFlow (i.e. after ExchangeCode succeeds).
|
|
// Error cases before ExchangeCode (missing state, provider error) have no flow and are
|
|
// handled client-side by the pollClosed interval.
|
|
func (h *MCPOAuthHandler) publishOAuthComplete(flow *mcpoauth.PendingFlow, status, errMsg string) {
|
|
if h.eventBus == nil {
|
|
return
|
|
}
|
|
bus.BroadcastForTenant(h.eventBus, protocol.EventMCPOAuthComplete, flow.TenantID,
|
|
protocol.MCPOAuthCompletePayload{
|
|
ServerID: flow.ServerID.String(),
|
|
UserID: flow.UserID,
|
|
InitiatingUserID: flow.InitiatingUserID,
|
|
Status: status,
|
|
Error: errMsg,
|
|
},
|
|
)
|
|
}
|
|
|
|
// --- GET /v1/mcp/oauth/status/{id} ---
|
|
|
|
type oauthStatusResp struct {
|
|
HasToken bool `json:"has_token"`
|
|
ClientID string `json:"client_id,omitempty"`
|
|
Issuer string `json:"issuer,omitempty"`
|
|
ExpiresAt *string `json:"expires_at,omitempty"`
|
|
Expired bool `json:"expired,omitempty"`
|
|
}
|
|
|
|
func (h *MCPOAuthHandler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
if !h.authorizeOAuthScope(w, r, r.URL.Query().Get("user_id")) {
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
serverID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server id"})
|
|
return
|
|
}
|
|
userID := r.URL.Query().Get("user_id")
|
|
|
|
var tok *store.MCPOAuthToken
|
|
if userID == "" {
|
|
tok, err = h.oauthStore.GetOAuthToken(ctx, serverID, tenantID)
|
|
} else {
|
|
tok, err = h.oauthStore.GetUserOAuthToken(ctx, serverID, tenantID, userID)
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
if tok == nil {
|
|
writeJSON(w, http.StatusOK, oauthStatusResp{HasToken: false})
|
|
return
|
|
}
|
|
|
|
resp := oauthStatusResp{
|
|
HasToken: true,
|
|
ClientID: tok.DCRClientID,
|
|
Issuer: tok.DCRIssuer,
|
|
}
|
|
if tok.ExpiresAt != nil {
|
|
s := tok.ExpiresAt.UTC().Format(time.RFC3339)
|
|
resp.ExpiresAt = &s
|
|
resp.Expired = time.Now().After(*tok.ExpiresAt)
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// --- DELETE /v1/mcp/oauth/token/{id} ---
|
|
|
|
func (h *MCPOAuthHandler) handleRevoke(w http.ResponseWriter, r *http.Request) {
|
|
if !h.authorizeOAuthScope(w, r, r.URL.Query().Get("user_id")) {
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
serverID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server id"})
|
|
return
|
|
}
|
|
userID := r.URL.Query().Get("user_id")
|
|
|
|
if userID == "" {
|
|
err = h.oauthStore.DeleteOAuthToken(ctx, serverID, tenantID)
|
|
} else {
|
|
err = h.oauthStore.DeleteUserOAuthToken(ctx, serverID, tenantID, userID)
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Invalidate cached token.
|
|
if h.refresher != nil {
|
|
h.refresher.InvalidateCache(serverID, userID)
|
|
}
|
|
|
|
// Evict pool connection so next request doesn't reuse the revoked token.
|
|
if h.evictor != nil {
|
|
srv, _ := h.mcpStore.GetServer(r.Context(), serverID)
|
|
if srv != nil {
|
|
h.evictor.EvictServer(tenantID, srv.Name)
|
|
}
|
|
}
|
|
|
|
// Broadcast MCP cache-invalidate → EvictAllUsers + agent reload so the revoked
|
|
// token is dropped everywhere and OAuth servers re-gate (hidden until re-auth).
|
|
h.emitMCPCacheInvalidate()
|
|
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|
|
|
|
// --- POST /v1/mcp/oauth/discover/{id} ---
|
|
|
|
type discoverResp struct {
|
|
Issuer string `json:"issuer,omitempty"`
|
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
|
TokenEndpoint string `json:"token_endpoint"`
|
|
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
|
|
ScopesSupported []string `json:"scopes_supported,omitempty"`
|
|
}
|
|
|
|
func (h *MCPOAuthHandler) handleDiscover(w http.ResponseWriter, r *http.Request) {
|
|
if !requireTenantAdmin(w, r, h.tenantStore) {
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
serverID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server id"})
|
|
return
|
|
}
|
|
srv, err := h.mcpStore.GetServer(ctx, serverID)
|
|
if err != nil || srv == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "MCP server not found"})
|
|
return
|
|
}
|
|
if srv.URL == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "server has no URL"})
|
|
return
|
|
}
|
|
|
|
discoverer := h.discoverer
|
|
if discoverer == nil {
|
|
discoverer = mcpoauth.NewDiscoverer(security.NewSafeClient(15 * time.Second))
|
|
}
|
|
disc, err := discoverer.Discover(ctx, srv.URL)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "discovery failed: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, discoverResp{
|
|
Issuer: disc.Issuer,
|
|
AuthorizationEndpoint: disc.AuthorizationEndpoint,
|
|
TokenEndpoint: disc.TokenEndpoint,
|
|
RegistrationEndpoint: disc.RegistrationEndpoint,
|
|
ScopesSupported: disc.ScopesSupported,
|
|
})
|
|
}
|