internal/mcpgateway/oauth_discovery.go
Rama main · réplica de lectura del repositorio original.
package mcpgateway
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"slices"
"strings"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
"github.com/enterpilot/gomodel/internal/version"
)
// ErrOAuthClientRequired means the authorization server offers no dynamic
// client registration, so the operator has to enter a client ID registered
// with it for the gateway's redirect URI.
var ErrOAuthClientRequired = errors.New("the authorization server does not support dynamic client registration; register an OAuth client for the redirect URI and enter its client ID")
// oauthDiscovery is what MCP authorization discovery found for one server.
type oauthDiscovery struct {
// resource is the canonical server URI from the protected resource
// metadata, sent as the RFC 8707 resource indicator.
resource string
scopes []string
asm *oauthex.AuthServerMeta
}
// discoverOAuth runs MCP authorization discovery for spec: an unauthenticated
// probe for the WWW-Authenticate challenge, protected resource metadata
// (RFC 9728), then authorization server metadata (RFC 8414 / OpenID
// Connect Discovery). It refuses servers without S256 PKCE support.
func discoverOAuth(ctx context.Context, client *http.Client, spec ServerSpec) (*oauthDiscovery, error) {
challenges := probeChallenges(ctx, client, spec)
prm, err := protectedResourceMetadata(ctx, client, challenges, spec.URL)
if err != nil {
return nil, err
}
issuer := prm.AuthorizationServers[0]
asm, err := auth.GetAuthServerMetadata(ctx, issuer, client)
if err != nil {
return nil, fmt.Errorf("read authorization server metadata of %s: %w", issuer, err)
}
if asm == nil {
return nil, fmt.Errorf("authorization server %s publishes no OAuth 2.0 or OpenID Connect metadata", issuer)
}
if !slices.Contains(asm.CodeChallengeMethodsSupported, "S256") {
return nil, fmt.Errorf("authorization server %s does not support S256 PKCE", issuer)
}
if asm.AuthorizationEndpoint == "" || asm.TokenEndpoint == "" {
return nil, fmt.Errorf("authorization server %s metadata lacks an authorization or token endpoint", issuer)
}
// Scope selection: the challenge's scope first, else every scope the
// resource lists. offline_access asks for a refresh token when the
// authorization server offers one.
scopes := challengeScopes(challenges)
if len(scopes) == 0 {
scopes = slices.Clone(prm.ScopesSupported)
}
if slices.Contains(asm.ScopesSupported, "offline_access") && !slices.Contains(scopes, "offline_access") {
scopes = append(scopes, "offline_access")
}
return &oauthDiscovery{resource: prm.Resource, scopes: scopes, asm: asm}, nil
}
// probeChallenges sends one unauthenticated request the way the transport
// opens a session and returns the Bearer challenges of a 401 or 403 answer.
// It is best effort: discovery falls back to the well-known URIs.
func probeChallenges(ctx context.Context, client *http.Client, spec ServerSpec) []oauthex.Challenge {
ctx, cancel := context.WithTimeout(ctx, connectTimeout)
defer cancel()
var req *http.Request
var err error
if spec.Transport == "sse" {
req, err = http.NewRequestWithContext(ctx, http.MethodGet, spec.URL, nil)
if err == nil {
req.Header.Set("Accept", "text/event-stream")
}
} else {
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"gomodel","version":"` + version.Version + `"}}}`
req, err = http.NewRequestWithContext(ctx, http.MethodPost, spec.URL, strings.NewReader(body))
if err == nil {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
}
}
if err != nil {
return nil
}
resp, err := client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusForbidden {
return nil
}
challenges, err := oauthex.ParseWWWAuthenticate(resp.Header.Values("WWW-Authenticate"))
if err != nil {
return nil
}
return challenges
}
// protectedResourceMetadata tries the challenge's resource_metadata URL, then
// the well-known URI at the endpoint's path, then the one at the root, as
// the MCP authorization spec orders them.
func protectedResourceMetadata(ctx context.Context, client *http.Client, challenges []oauthex.Challenge, serverURL string) (*oauthex.ProtectedResourceMetadata, error) {
var lastErr error
for _, candidate := range protectedResourceMetadataURLs(challengeParam(challenges, "resource_metadata"), serverURL) {
prm, err := oauthex.GetProtectedResourceMetadata(ctx, candidate.url, candidate.resource, client)
if err != nil {
lastErr = err
continue
}
if len(prm.AuthorizationServers) == 0 {
return nil, errors.New("protected resource metadata lists no authorization servers")
}
return prm, nil
}
if lastErr != nil {
return nil, fmt.Errorf("the server publishes no usable OAuth protected resource metadata: %w", redactDialError(lastErr))
}
return nil, errors.New("the server publishes no OAuth protected resource metadata")
}
type prmCandidate struct {
url string
resource string
}
func protectedResourceMetadataURLs(metadataURL, serverURL string) []prmCandidate {
var candidates []prmCandidate
if metadataURL != "" {
candidates = append(candidates, prmCandidate{url: metadataURL, resource: serverURL})
}
parsed, err := url.Parse(serverURL)
if err != nil {
return candidates
}
wellKnown := *parsed
wellKnown.RawQuery = ""
wellKnown.Fragment = ""
// RawPath keeps escaped separators such as %2F in the endpoint's path.
if path := strings.Trim(parsed.EscapedPath(), "/"); path != "" {
wellKnown.Path = "/.well-known/oauth-protected-resource/" + strings.Trim(parsed.Path, "/")
wellKnown.RawPath = "/.well-known/oauth-protected-resource/" + path
candidates = append(candidates, prmCandidate{url: wellKnown.String(), resource: serverURL})
}
wellKnown.Path = "/.well-known/oauth-protected-resource"
wellKnown.RawPath = ""
root := url.URL{Scheme: parsed.Scheme, Host: parsed.Host}
candidates = append(candidates, prmCandidate{url: wellKnown.String(), resource: root.String()})
return candidates
}
func challengeParam(challenges []oauthex.Challenge, name string) string {
for _, challenge := range challenges {
if value := challenge.Params[name]; value != "" {
return value
}
}
return ""
}
func challengeScopes(challenges []oauthex.Challenge) []string {
for _, challenge := range challenges {
if challenge.Scheme == "bearer" && challenge.Params["scope"] != "" {
return strings.Fields(challenge.Params["scope"])
}
}
return nil
}
// oauthClient is the OAuth client the gateway authorizes as.
type oauthClient struct {
id string
secret string
authStyle oauth2.AuthStyle
registered bool
}
// resolveOAuthClient picks the client in the spec's priority order: the
// pre-registered client the operator entered, the client stored from an
// earlier sign-in with the same authorization server and redirect URI, then
// dynamic client registration (RFC 7591). Issuers compare exactly, as RFC
// 8414 requires, so a stored client never follows a different issuer.
func resolveOAuthClient(ctx context.Context, client *http.Client, asm *oauthex.AuthServerMeta, req OAuthStartRequest, stored *oauthRecord) (oauthClient, error) {
if id := strings.TrimSpace(req.ClientID); id != "" {
secret := strings.TrimSpace(req.ClientSecret)
return oauthClient{id: id, secret: secret, authStyle: tokenAuthStyle(asm.TokenEndpointAuthMethodsSupported, secret)}, nil
}
if stored != nil && stored.ClientID != "" && stored.Issuer == asm.Issuer && stored.RedirectURI == req.RedirectURI {
return oauthClient{id: stored.ClientID, secret: stored.ClientSecret, authStyle: oauth2.AuthStyle(stored.AuthStyle), registered: stored.Registered}, nil
}
if asm.RegistrationEndpoint == "" {
return oauthClient{}, ErrOAuthClientRequired
}
registered, err := oauthex.RegisterClient(ctx, asm.RegistrationEndpoint, &oauthex.ClientRegistrationMetadata{
RedirectURIs: []string{req.RedirectURI},
TokenEndpointAuthMethod: registrationAuthMethod(asm.TokenEndpointAuthMethodsSupported),
GrantTypes: []string{"authorization_code", "refresh_token"},
ResponseTypes: []string{"code"},
ClientName: "GoModel",
SoftwareID: "gomodel",
SoftwareVersion: version.Version,
ApplicationType: applicationType(req.RedirectURI),
}, client)
if err != nil {
return oauthClient{}, registrationError(err)
}
return oauthClient{
id: registered.ClientID,
secret: registered.ClientSecret,
authStyle: registeredAuthStyle(registered.TokenEndpointAuthMethod, registered.ClientSecret),
registered: true,
}, nil
}
// registrationError keeps the RFC 7591 error and drops response bodies the
// SDK quotes, which for a malformed success response include the secret.
func registrationError(err error) error {
if regErr, ok := errors.AsType[*oauthex.ClientRegistrationError](err); ok {
if regErr.ErrorDescription != "" {
return fmt.Errorf("dynamic client registration failed: %s: %s", regErr.ErrorCode, truncateOAuthText(regErr.ErrorDescription))
}
return fmt.Errorf("dynamic client registration failed: %s", regErr.ErrorCode)
}
if _, ok := errors.AsType[*url.Error](err); ok {
return fmt.Errorf("dynamic client registration failed: %w", redactDialError(err))
}
return errors.New("dynamic client registration failed: the authorization server returned an unexpected response")
}
// registrationAuthMethod picks the token endpoint auth method to register
// with: a public client ("none") when the authorization server allows it or
// lists nothing, else the first client secret method it lists. The method
// the server actually assigned is read back from the registration response.
func registrationAuthMethod(supported []string) string {
if len(supported) == 0 || slices.Contains(supported, "none") {
return "none"
}
for _, method := range []string{"client_secret_basic", "client_secret_post"} {
if slices.Contains(supported, method) {
return method
}
}
return "none"
}
// applicationType is "native" for a loopback redirect URI and "web"
// otherwise, as OpenID Connect registration expects.
func applicationType(redirectURI string) string {
parsed, err := url.Parse(redirectURI)
if err == nil && isLoopbackHost(parsed.Hostname()) {
return "native"
}
return "web"
}
func isLoopbackHost(host string) bool {
if strings.EqualFold(host, "localhost") {
return true
}
ip, err := netip.ParseAddr(host)
return err == nil && ip.Unmap().IsLoopback()
}
// tokenAuthStyle picks how a pre-registered client authenticates at the
// token endpoint: no secret means a public client.
func tokenAuthStyle(supported []string, secret string) oauth2.AuthStyle {
if secret == "" {
return oauth2.AuthStyleInParams
}
switch {
case slices.Contains(supported, "client_secret_post"):
return oauth2.AuthStyleInParams
case slices.Contains(supported, "client_secret_basic"):
return oauth2.AuthStyleInHeader
}
return oauth2.AuthStyleAutoDetect
}
func registeredAuthStyle(method, secret string) oauth2.AuthStyle {
switch {
case secret == "", method == "none", method == "client_secret_post":
return oauth2.AuthStyleInParams
default:
// client_secret_basic is the RFC 7591 default.
return oauth2.AuthStyleInHeader
}
}
// resourceClient returns client with token requests to tokenURL carrying
// the RFC 8707 resource parameter. The oauth2 package adds it to the code
// exchange on request, but has no option for the refresh-token grant.
func resourceClient(client *http.Client, tokenURL, resource string) *http.Client {
if client == nil {
client = http.DefaultClient
}
base := client.Transport
if base == nil {
base = http.DefaultTransport
}
clone := *client
clone.Transport = &resourceRoundTripper{base: base, tokenURL: tokenURL, resource: resource}
return &clone
}
type resourceRoundTripper struct {
base http.RoundTripper
tokenURL string
resource string
}
func (t *resourceRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if t.resource == "" || req.Method != http.MethodPost || req.Body == nil || req.URL.String() != t.tokenURL {
return t.base.RoundTrip(req)
}
data, err := io.ReadAll(req.Body)
_ = req.Body.Close()
if err != nil {
return nil, err
}
form, err := url.ParseQuery(string(data))
if err != nil {
return nil, fmt.Errorf("token request body is not a form")
}
if form.Get("resource") == "" {
form.Set("resource", t.resource)
}
encoded := form.Encode()
clone := req.Clone(req.Context())
clone.Body = io.NopCloser(bytes.NewBufferString(encoded))
clone.ContentLength = int64(len(encoded))
clone.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewBufferString(encoded)), nil }
return t.base.RoundTrip(clone)
}