internal/mcpgateway/oauth_source.go
Rama main · réplica de lectura del repositorio original.
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
}