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