package mcpgateway import ( "context" "crypto/rand" "errors" "fmt" "net/http" "slices" "sync" "time" "golang.org/x/oauth2" ) // OAuth sign-in states shown in admin views. const ( OAuthStatusSignedIn = "signed_in" OAuthStatusExpired = "expired" OAuthStatusError = "error" ) // oauthSaveTimeout bounds persisting a refreshed token. const oauthSaveTimeout = 10 * time.Second // oauthLookupTimeout bounds one read of the stored record around a refresh. const oauthLookupTimeout = 3 * time.Second // oauthRefreshTimeout bounds one token-endpoint request, so a stalled // authorization server cannot hold up a tool call indefinitely. const oauthRefreshTimeout = 15 * time.Second // errOAuthExpired means the access token expired and no refresh token can // renew it: an operator has to sign in again. var errOAuthExpired = errors.New("the OAuth access token expired and the server issued no refresh token; sign in again from the dashboard") // errOAuthRejected means the MCP server refused an unexpired access token // and no refresh token can replace it: an operator has to sign in again. var errOAuthRejected = errors.New("the mcp server rejected the OAuth access token and the server issued no refresh token; sign in again from the dashboard") // OAuthView is the admin-facing sign-in state of one server. It never // carries a token or client secret. type OAuthView struct { Status string Issuer string Scopes []string ExpiresAt time.Time SignedInAt time.Time LastError string } // oauthSource hands out access tokens for one signed-in server. It refreshes // an expired token with the refresh token and persists the result, because // authorization servers rotate refresh tokens for public clients. // // mu guards record and lastErr and is never held across I/O, so admin views // and reloads never wait for a refresh. refreshing admits one refresh at a // time: callers that waited for it reuse its result. type oauthSource struct { name string store *oauthStore httpClient *http.Client // owner names this source in the stored refresh lease. owner string refreshing chan struct{} mu sync.Mutex record oauthRecord lastErr string } func newOAuthSource(name string, record oauthRecord, store *oauthStore, httpClient *http.Client) *oauthSource { return &oauthSource{ name: name, record: record, store: store, httpClient: httpClient, owner: rand.Text(), refreshing: make(chan struct{}, 1), } } // sameSignIn reports whether record comes from the sign-in this source was // built from, as opposed to a new one that needs a fresh upstream session. func (o *oauthSource) sameSignIn(record oauthRecord) bool { o.mu.Lock() defer o.mu.Unlock() return o.record.sameSignIn(record) } // adopt takes tokens another instance refreshed and stored after this one // last did, so a rotated refresh token is not reused here. func (o *oauthSource) adopt(record oauthRecord) { o.mu.Lock() defer o.mu.Unlock() if record.UpdatedAt.After(o.record.UpdatedAt) { o.record = record o.lastErr = "" } } func (o *oauthSource) view() *OAuthView { o.mu.Lock() defer o.mu.Unlock() view := &OAuthView{ Status: OAuthStatusSignedIn, Issuer: o.record.Issuer, Scopes: slices.Clone(o.record.Scopes), ExpiresAt: o.record.Expiry, SignedInAt: o.record.SignedInAt, LastError: o.lastErr, } switch { case o.lastErr != "": view.Status = OAuthStatusError case o.record.RefreshToken == "" && !o.record.token().Valid(): view.Status = OAuthStatusExpired } return view } // token returns the record's token set. func (r oauthRecord) token() *oauth2.Token { return &oauth2.Token{ AccessToken: r.AccessToken, RefreshToken: r.RefreshToken, TokenType: r.TokenType, Expiry: r.Expiry, } } // usable reports whether r holds a valid access token other than rejected. func (r oauthRecord) usable(rejected string) bool { return r.token().Valid() && (rejected == "" || r.AccessToken != rejected) } // accessToken returns a valid access token, refreshing it when it expired. func (o *oauthSource) accessToken(ctx context.Context) (string, error) { return o.tokenOtherThan(ctx, "") } // tokenOtherThan returns a valid access token other than rejected, one the // upstream refused before it expired: a token another request or instance // obtained meanwhile, else a refreshed one. An empty rejected only refreshes // an expired token. func (o *oauthSource) tokenOtherThan(ctx context.Context, rejected string) (string, error) { if token, ok := o.current(rejected); ok { return token, nil } // A canceled caller still gets an idle slot: its refresh is detached. select { case o.refreshing <- struct{}{}: default: select { case o.refreshing <- struct{}{}: case <-ctx.Done(): return "", ctx.Err() } } defer func() { <-o.refreshing }() // A refresh that finished while this caller waited is shared. if token, ok := o.current(rejected); ok { return token, nil } record, err := o.renew(ctx, rejected) o.mu.Lock() defer o.mu.Unlock() // A grant that cannot be retried is still the sign-in's current tokens. if record.AccessToken != "" && !o.record.UpdatedAt.After(record.UpdatedAt) { o.record = record } if err != nil { o.lastErr = err.Error() if errors.Is(err, errOAuthExpired) || errors.Is(err, errOAuthRejected) { return "", err } return "", fmt.Errorf("refresh the OAuth token of mcp server %q: %w; sign in again from the dashboard if it persists", o.name, err) } // A reload may have adopted a newer stored token meanwhile; it is the // one to hand out while it works. o.lastErr = "" if o.record.UpdatedAt.After(record.UpdatedAt) && o.record.usable(rejected) { return o.record.AccessToken, nil } return record.AccessToken, nil } // rejected records that the upstream refused token right after a refresh, // so the sign-in shows the problem instead of "signed in". func (o *oauthSource) rejected(token string) { o.mu.Lock() defer o.mu.Unlock() if o.record.AccessToken == token { o.lastErr = "the mcp server rejected a freshly refreshed access token; check the sign-in's scopes or sign in again from the dashboard" } } // closeIdle drops the idle connections of this source's token-endpoint // client once the source is retired. func (o *oauthSource) closeIdle() { if o.httpClient != nil { o.httpClient.CloseIdleConnections() } } // current returns the in-memory access token when it is usable. func (o *oauthSource) current(rejected string) (string, bool) { o.mu.Lock() defer o.mu.Unlock() if o.record.usable(rejected) { return o.record.AccessToken, true } return "", false } // snapshot copies the in-memory record. func (o *oauthSource) snapshot() oauthRecord { o.mu.Lock() defer o.mu.Unlock() return o.record } // serverURL is the upstream URL this source's tokens were issued for. func (o *oauthSource) serverURL() string { o.mu.Lock() defer o.mu.Unlock() return o.record.ServerURL } // oauthTokenError keeps the OAuth error code and description of a failed // token request and drops the raw response body the oauth2 package quotes. func oauthTokenError(err error) error { if retrieve, ok := errors.AsType[*oauth2.RetrieveError](err); ok { if retrieve.ErrorCode != "" { if retrieve.ErrorDescription != "" { return fmt.Errorf("token endpoint returned %s: %s", retrieve.ErrorCode, truncateOAuthText(retrieve.ErrorDescription)) } return fmt.Errorf("token endpoint returned %s", retrieve.ErrorCode) } if retrieve.Response != nil { return fmt.Errorf("token endpoint returned HTTP %d", retrieve.Response.StatusCode) } return errors.New("token request failed") } return redactDialError(err) } func truncateOAuthText(text string) string { const limit = 200 runes := []rune(text) if len(runes) > limit { return string(runes[:limit]) + "…" } return text } // bearerRoundTripper sets the OAuth access token on every request to the // server's origin. The static-header transport wraps it, so a signed-in // token replaces a configured Authorization header. type bearerRoundTripper struct { base http.RoundTripper source *oauthSource origin string } func (t *bearerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { if t.origin == "" || requestOrigin(req.URL.String()) != t.origin { return t.base.RoundTrip(req) } token, err := t.source.accessToken(req.Context()) if err != nil { if req.Body != nil { _ = req.Body.Close() } return nil, err } resp, err := t.base.RoundTrip(withBearer(req, token)) if err != nil || resp.StatusCode != http.StatusUnauthorized { return resp, err } // The server refused a token that had not expired, such as one revoked // early or issued without expires_in: refresh it and retry once. A failed // refresh shows on the server's sign-in state, and the 401 goes back. fresh, err := t.source.tokenOtherThan(req.Context(), token) if err != nil { return resp, nil } retry, ok := rewindRequest(req) if !ok { return resp, nil } _ = resp.Body.Close() resp, err = t.base.RoundTrip(withBearer(retry, fresh)) if err == nil && resp.StatusCode == http.StatusUnauthorized { t.source.rejected(fresh) } return resp, err } func withBearer(req *http.Request, token string) *http.Request { clone := req.Clone(req.Context()) clone.Header.Set("Authorization", "Bearer "+token) return clone } // rewindRequest returns a copy of req that can be sent again after req was: // one without a body, or with a body GetBody can produce again. func rewindRequest(req *http.Request) (*http.Request, bool) { if req.Body == nil || req.Body == http.NoBody { return req, true } if req.GetBody == nil { return nil, false } body, err := req.GetBody() if err != nil { return nil, false } clone := req.Clone(req.Context()) clone.Body = body return clone, true }