internal/mcpgateway/oauth.go
Rama main · réplica de lectura del repositorio original.
package mcpgateway
import (
"context"
"crypto/rand"
"crypto/subtle"
"errors"
"fmt"
"log/slog"
"maps"
"net/http"
"net/url"
"strings"
"sync"
"time"
"golang.org/x/oauth2"
"github.com/enterpilot/gomodel/internal/encryption"
)
// OAuthCallbackPath is where authorization servers send the operator back
// after an MCP server sign-in. It sits outside the authenticated admin API:
// the browser arrives without the dashboard's bearer token, and the single-use
// state plus a browser-binding cookie authenticate the callback instead.
const OAuthCallbackPath = "/admin/mcp-oauth/callback"
// oauthFlowTTL bounds how long a started sign-in waits for its callback.
const oauthFlowTTL = 10 * time.Minute
// maxPendingOAuthFlows caps unfinished sign-ins kept in memory.
const maxPendingOAuthFlows = 64
// oauthStartTimeout bounds discovery and client registration, and
// oauthExchangeTimeout the code exchange: the shared upstream client has no
// whole-request timeout because SSE upstreams stream for hours.
const (
oauthStartTimeout = 30 * time.Second
oauthExchangeTimeout = 15 * time.Second
)
var (
// ErrOAuthUnavailable means the store cannot hold OAuth records.
ErrOAuthUnavailable = errors.New("mcp oauth sign-in is unavailable without a store")
// ErrOAuthInvalidRequest wraps sign-in input the caller has to fix.
ErrOAuthInvalidRequest = errors.New("invalid mcp oauth request")
// ErrOAuthFlowUnknown means the callback's state matches no pending
// sign-in on this instance: it expired, was used, or never existed.
ErrOAuthFlowUnknown = errors.New("the sign-in expired or was already completed; start it again from the dashboard")
)
// OAuthStartRequest starts an MCP server sign-in.
type OAuthStartRequest struct {
// RedirectURI is the absolute URL of OAuthCallbackPath as the operator's
// browser reaches the gateway.
RedirectURI string
// ClientID and ClientSecret name a client the operator registered with
// the authorization server. Empty uses the stored client or dynamic
// client registration.
ClientID string
ClientSecret string
}
// OAuthStart is a started sign-in: the browser goes to AuthorizationURL with
// the binding cookie set, which the callback has to present. State names the
// flow, so concurrent sign-ins each keep their own cookie.
type OAuthStart struct {
AuthorizationURL string
State string
Binding string
}
// OAuthCallback is the authorization response as the browser delivered it.
type OAuthCallback struct {
State string
Code string
Issuer string
Error string
ErrorDescription string
Binding string
}
type oauthFlow struct {
server string
binding string
verifier string
issuer string
issSupported bool
config *oauth2.Config
record oauthRecord
expires time.Time
// generation is the server's sign-out count when the flow started; a
// later sign-out or removal makes the flow stale.
generation uint64
}
// oauthManager holds the token sources of signed-in servers, the sign-ins in
// progress, and the last sign-in failure per server.
type oauthManager struct {
store *oauthStore
httpClient *http.Client
mu sync.Mutex
sources map[string]*oauthSource
pending map[string]*oauthFlow
failures map[string]oauthFailure
generations map[string]uint64
}
// oauthFailure is the last sign-in failure of a server at url; it is not
// shown once the server's URL changed.
type oauthFailure struct {
url string
message string
}
func newOAuthManager(store *oauthStore, httpClient *http.Client) *oauthManager {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &oauthManager{
store: store,
httpClient: httpClient,
sources: make(map[string]*oauthSource),
pending: make(map[string]*oauthFlow),
failures: make(map[string]oauthFailure),
generations: make(map[string]uint64),
}
}
// attach sets each HTTP spec's token source from the stored records: a new
// sign-in gets a new source, so the manager redials with it; tokens another
// instance refreshed are adopted in place. A server whose URL changed since
// sign-in does not get the tokens. keepMissing keeps the current source of a
// server whose record could not be read. generations is the sign-out count
// per server from before records were read: a server signed out since then
// gets no source, so a stale snapshot never brings its tokens back. The
// decisions run without m.mu, so admin views never wait on a source; a
// source's own lock is never held across a token refresh either.
func (m *oauthManager) attach(specs []ServerSpec, records map[string]oauthRecord, keepMissing bool, generations map[string]uint64) {
if m == nil {
return
}
m.mu.Lock()
previous := maps.Clone(m.sources)
m.mu.Unlock()
sources := make(map[string]*oauthSource, len(records))
for i := range specs {
spec := &specs[i]
spec.oauth = nil
if !oauthTransport(spec.Transport) || !oauthSecureURL(spec.URL) {
continue
}
current := previous[spec.Name]
record, ok := records[spec.Name]
switch {
case !ok && keepMissing && current != nil && current.serverURL() == spec.URL:
spec.oauth = current
case !ok || record.ServerURL != spec.URL:
case current != nil && current.sameSignIn(record):
current.adopt(record)
spec.oauth = current
default:
spec.oauth = newOAuthSource(spec.Name, record, m.store, oauthHTTPClient(m.httpClient, spec.URL))
}
if spec.oauth != nil {
sources[spec.Name] = spec.oauth
}
}
m.mu.Lock()
defer m.mu.Unlock()
for i := range specs {
spec := &specs[i]
if spec.oauth != nil && m.generations[spec.Name] != generations[spec.Name] {
delete(sources, spec.Name)
spec.oauth = nil
}
}
m.sources = sources
// A retired source keeps no idle connections to its token endpoint.
for name, old := range previous {
if sources[name] != old {
old.closeIdle()
}
}
}
// view is the sign-in state of name: its source, else its last failure.
func (m *oauthManager) view(spec ServerSpec) *OAuthView {
if m == nil {
return nil
}
m.mu.Lock()
var failure string
if last, ok := m.failures[spec.Name]; ok && last.url == spec.URL {
failure = last.message
}
m.mu.Unlock()
if spec.oauth != nil {
view := spec.oauth.view()
if failure != "" && view.LastError == "" {
view.LastError = failure
}
return view
}
if failure != "" {
return &OAuthView{Status: OAuthStatusError, LastError: failure}
}
return nil
}
func (m *oauthManager) fail(name, serverURL string, err error) {
m.mu.Lock()
m.failures[name] = oauthFailure{url: serverURL, message: err.Error()}
m.mu.Unlock()
}
// signedOut forgets everything held for name after a sign-out or removal:
// its token source, so a reload that cannot read the store does not keep
// the tokens, and its pending sign-ins, so a late callback cannot sign the
// server back in.
func (m *oauthManager) signedOut(name string) {
m.mu.Lock()
defer m.mu.Unlock()
if source := m.sources[name]; source != nil {
source.closeIdle()
}
delete(m.sources, name)
delete(m.failures, name)
m.generations[name]++
for state, flow := range m.pending {
if flow.server == name {
delete(m.pending, state)
}
}
}
// generationsSnapshot copies every server's sign-out count, taken before a
// reload reads the stored records.
func (m *oauthManager) generationsSnapshot() map[string]uint64 {
m.mu.Lock()
defer m.mu.Unlock()
return maps.Clone(m.generations)
}
// generation is name's sign-out count, captured when a sign-in starts.
func (m *oauthManager) generation(name string) uint64 {
m.mu.Lock()
defer m.mu.Unlock()
return m.generations[name]
}
// current reports whether no sign-out or removal happened since flow
// started.
func (m *oauthManager) current(flow *oauthFlow) bool {
m.mu.Lock()
defer m.mu.Unlock()
return flow.generation == m.generations[flow.server]
}
func (m *oauthManager) clearFailure(name string) {
m.mu.Lock()
delete(m.failures, name)
m.mu.Unlock()
}
func (m *oauthManager) addFlow(state string, flow *oauthFlow) {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
var oldest string
for key, pending := range m.pending {
if now.After(pending.expires) {
delete(m.pending, key)
continue
}
if oldest == "" || pending.expires.Before(m.pending[oldest].expires) {
oldest = key
}
}
if len(m.pending) >= maxPendingOAuthFlows && oldest != "" {
delete(m.pending, oldest)
}
m.pending[state] = flow
}
// takeFlow removes and returns the pending flow for state, so a state works
// once.
func (m *oauthManager) takeFlow(state string) (*oauthFlow, bool) {
m.mu.Lock()
defer m.mu.Unlock()
flow, ok := m.pending[state]
delete(m.pending, state)
if !ok || time.Now().After(flow.expires) {
return nil, false
}
return flow, true
}
func oauthTransport(transport string) bool {
return transport == "" || transport == "http" || transport == "sse"
}
// oauthSecureURL reports whether bearer tokens may go to serverURL: https,
// or plain http on a loopback host for local development (RFC 6750 §5.3).
func oauthSecureURL(serverURL string) bool {
parsed, err := url.Parse(serverURL)
if err != nil {
return false
}
switch parsed.Scheme {
case "https":
return true
case "http":
return isLoopbackHost(parsed.Hostname())
}
return false
}
// StartOAuth runs MCP authorization discovery for server name and returns
// the authorization URL the operator's browser opens. Config-declared and
// dashboard-managed HTTP servers can both sign in: tokens are runtime state,
// not part of the server definition.
func (s *Service) StartOAuth(ctx context.Context, name string, req OAuthStartRequest) (OAuthStart, error) {
if s.oauth == nil || s.oauth.store == nil {
return OAuthStart{}, ErrOAuthUnavailable
}
// Taken first, so a sign-out during discovery makes this flow stale.
generation := s.oauth.generation(name)
spec, ok := s.manager.spec(name)
if !ok {
return OAuthStart{}, ErrNotFound
}
if !oauthTransport(spec.Transport) {
return OAuthStart{}, fmt.Errorf("%w: OAuth sign-in needs an http or sse server", ErrOAuthInvalidRequest)
}
if !oauthSecureURL(spec.URL) {
return OAuthStart{}, fmt.Errorf("%w: OAuth sign-in needs an https server URL; plain http is only allowed on localhost", ErrOAuthInvalidRequest)
}
req.RedirectURI = strings.TrimSpace(req.RedirectURI)
if err := ValidateOAuthRedirectURI(req.RedirectURI); err != nil {
return OAuthStart{}, err
}
startCtx, cancel := context.WithTimeout(ctx, oauthStartTimeout)
defer cancel()
start, err := s.startOAuth(startCtx, spec, req, generation)
if err != nil {
s.oauth.fail(name, spec.URL, err)
return OAuthStart{}, err
}
return start, nil
}
func (s *Service) startOAuth(ctx context.Context, spec ServerSpec, req OAuthStartRequest, generation uint64) (OAuthStart, error) {
httpClient := oauthHTTPClient(s.oauth.httpClient, spec.URL)
defer httpClient.CloseIdleConnections()
discovery, err := discoverOAuth(ctx, httpClient, spec)
if err != nil {
return OAuthStart{}, err
}
stored, err := s.oauth.store.get(ctx, spec.Name)
if err != nil && !errors.Is(err, ErrNotFound) {
slog.Warn("stored mcp oauth record could not be read; registering a new client", "server", spec.Name, "error", err)
}
client, err := resolveOAuthClient(ctx, httpClient, discovery.asm, req, stored)
if err != nil {
return OAuthStart{}, err
}
record := oauthRecord{
ServerURL: spec.URL,
Resource: discovery.resource,
Issuer: discovery.asm.Issuer,
TokenURL: discovery.asm.TokenEndpoint,
RedirectURI: req.RedirectURI,
ClientID: client.id,
ClientSecret: client.secret,
AuthStyle: int(client.authStyle),
Registered: client.registered,
Scopes: discovery.scopes,
}
config := record.config(discovery.asm.AuthorizationEndpoint)
verifier := oauth2.GenerateVerifier()
state := rand.Text()
authURL := config.AuthCodeURL(state,
oauth2.S256ChallengeOption(verifier),
oauth2.SetAuthURLParam("resource", record.Resource),
)
binding := rand.Text()
s.oauth.addFlow(state, &oauthFlow{
server: spec.Name,
binding: binding,
verifier: verifier,
issuer: discovery.asm.Issuer,
issSupported: discovery.asm.AuthorizationResponseIssParameterSupported,
config: config,
record: record,
expires: time.Now().Add(oauthFlowTTL),
generation: generation,
})
return OAuthStart{AuthorizationURL: authURL, State: state, Binding: binding}, nil
}
// CompleteOAuth finishes a sign-in from the authorization response: it
// checks the state, browser binding, and issuer, exchanges the code with the
// PKCE verifier, stores the tokens sealed, and reconnects the server. It
// returns the server name whenever the state matched a pending sign-in.
func (s *Service) CompleteOAuth(ctx context.Context, callback OAuthCallback) (string, error) {
if s.oauth == nil || s.oauth.store == nil {
return "", ErrOAuthUnavailable
}
flow, ok := s.oauth.takeFlow(callback.State)
if !ok {
return "", ErrOAuthFlowUnknown
}
if err := s.completeOAuth(ctx, flow, callback); err != nil {
s.oauth.fail(flow.server, flow.record.ServerURL, err)
return flow.server, err
}
s.oauth.clearFailure(flow.server)
// The sign-in is stored; other gateway instances reload it.
defer s.notifyChanged(ctx)
if err := s.Reload(ctx); err != nil {
return flow.server, fmt.Errorf("signed in, but the server was not reconnected: %w", err)
}
return flow.server, nil
}
func (s *Service) completeOAuth(ctx context.Context, flow *oauthFlow, callback OAuthCallback) error {
if flow.binding == "" || subtle.ConstantTimeCompare([]byte(flow.binding), []byte(callback.Binding)) != 1 {
return errors.New("the sign-in was finished in a different browser than the one that started it")
}
if err := validateAuthorizationIssuer(callback.Issuer, flow.issuer, flow.issSupported); err != nil {
return err
}
if callback.Error != "" {
if callback.ErrorDescription != "" {
return fmt.Errorf("the authorization server refused the sign-in: %s: %s", truncateOAuthText(callback.Error), truncateOAuthText(callback.ErrorDescription))
}
return fmt.Errorf("the authorization server refused the sign-in: %s", truncateOAuthText(callback.Error))
}
if callback.Code == "" {
return errors.New("the authorization response carried no code")
}
exchangeCtx, cancel := context.WithTimeout(ctx, oauthExchangeTimeout)
defer cancel()
httpClient := oauthHTTPClient(s.oauth.httpClient, flow.record.ServerURL)
defer httpClient.CloseIdleConnections()
exchangeCtx = context.WithValue(exchangeCtx, oauth2.HTTPClient, httpClient)
token, err := flow.config.Exchange(exchangeCtx, callback.Code,
oauth2.VerifierOption(flow.verifier),
oauth2.SetAuthURLParam("resource", flow.record.Resource),
)
if err != nil {
return fmt.Errorf("exchange the authorization code: %w", oauthTokenError(err))
}
record := flow.record
record.AccessToken = token.AccessToken
record.RefreshToken = token.RefreshToken
record.TokenType = token.TokenType
record.Expiry = token.Expiry
if granted, ok := token.Extra("scope").(string); ok && strings.TrimSpace(granted) != "" {
record.Scopes = strings.Fields(granted)
}
now := time.Now().UTC()
record.SignedInAt = now
record.UpdatedAt = now
// A sign-out, removal, or URL change while the sign-in ran wins.
// mutateMu, which sign-outs and removals hold, keeps one from landing
// between the check and the write.
s.mutateMu.Lock()
defer s.mutateMu.Unlock()
if spec, ok := s.manager.spec(flow.server); !ok || spec.URL != record.ServerURL || !s.oauth.current(flow) {
return errors.New("the server was signed out, removed, or changed during the sign-in; start it again")
}
if err := s.oauth.store.put(ctx, flow.server, record); err != nil {
if !errors.Is(err, encryption.ErrSealUnconfirmed) {
return err
}
// The record was written; only its data key is unconfirmed.
slog.Warn("mcp oauth sign-in saved, but its data key could not be confirmed", "server", flow.server, "error", err)
}
return nil
}
// validateAuthorizationIssuer applies RFC 9207 as the MCP authorization spec
// requires: a present iss must equal the recorded issuer exactly, and an
// absent one is rejected only when the server advertises iss support.
func validateAuthorizationIssuer(iss, expected string, supported bool) error {
if iss == "" {
if supported {
return errors.New("the authorization response lacks the iss parameter its server advertises")
}
return nil
}
if iss != expected {
return errors.New("the authorization response came from an unexpected issuer")
}
return nil
}
// SignOutOAuth deletes the stored OAuth record of name and reconnects the
// server without it.
func (s *Service) SignOutOAuth(ctx context.Context, name string) error {
if s.oauth == nil || s.oauth.store == nil {
return ErrOAuthUnavailable
}
if _, ok := s.manager.spec(name); !ok {
return ErrNotFound
}
if err := s.signOut(ctx, name); err != nil {
return err
}
defer s.notifyChanged(ctx)
return s.Reload(ctx)
}
// signOut deletes the stored record and forgets the server's sign-in state
// under mutateMu, so a callback finishing meanwhile cannot store tokens again.
func (s *Service) signOut(ctx context.Context, name string) error {
s.mutateMu.Lock()
defer s.mutateMu.Unlock()
if err := s.oauth.store.delete(ctx, name); err != nil {
return err
}
s.oauth.signedOut(name)
return nil
}
// forgetOAuth deletes a removed server's OAuth record. It is best effort:
// the server is already gone.
func (s *Service) forgetOAuth(ctx context.Context, name string) {
if s.oauth == nil || s.oauth.store == nil {
return
}
if err := s.oauth.store.delete(ctx, name); err != nil {
slog.Warn("failed to delete the oauth record of a removed mcp server", "server", name, "error", err)
}
s.oauth.signedOut(name)
}
// ValidateOAuthRedirectURI accepts an absolute http(s) URL without query or
// fragment whose path ends in OAuthCallbackPath, optionally under a base
// path.
func ValidateOAuthRedirectURI(raw string) error {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return fmt.Errorf("%w: redirect_uri must be an absolute http or https URL", ErrOAuthInvalidRequest)
}
if parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
return fmt.Errorf("%w: redirect_uri must not carry a query, fragment, or credentials", ErrOAuthInvalidRequest)
}
if !strings.HasSuffix(parsed.Path, OAuthCallbackPath) {
return fmt.Errorf("%w: redirect_uri must end in %s", ErrOAuthInvalidRequest, OAuthCallbackPath)
}
return nil
}
// attachOAuth gives specs the token sources of their stored sign-ins. A
// record that cannot be read keeps the server's current tokens, so a store
// hiccup never signs a working server out.
func (s *Service) attachOAuth(ctx context.Context, specs []ServerSpec) {
if s.oauth == nil || s.oauth.store == nil {
return
}
generations := s.oauth.generationsSnapshot()
records, err := s.oauth.store.list(ctx)
if err != nil {
slog.Warn("some mcp oauth sign-ins could not be read; keeping their current tokens", "error", err)
}
s.oauth.attach(specs, records, err != nil, generations)
}