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) }