288 lines
8.6 KiB
Go
288 lines
8.6 KiB
Go
// Package oauth provides OAuth 2.0 Protected Resource Metadata (RFC 9728) support
|
|
// for the GitHub MCP Server HTTP mode.
|
|
package oauth
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/github/github-mcp-server/pkg/http/headers"
|
|
"github.com/github/github-mcp-server/pkg/utils"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/modelcontextprotocol/go-sdk/auth"
|
|
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
|
)
|
|
|
|
const (
|
|
// OAuthProtectedResourcePrefix is the well-known path prefix for OAuth protected resource metadata.
|
|
OAuthProtectedResourcePrefix = "/.well-known/oauth-protected-resource"
|
|
)
|
|
|
|
// SupportedScopes lists every OAuth scope that an MCP tool may require. It is the
|
|
// source of truth in two places: HTTP mode advertises it as scopes_supported in
|
|
// the protected-resource metadata, and stdio OAuth login requests it by default
|
|
// and then filters the exposed tools to the granted scopes. A tool whose required
|
|
// scope is absent here is therefore hidden under default OAuth even though a PAT
|
|
// carrying that scope would expose it, so keep this list in sync with tool scope
|
|
// requirements when scopes change.
|
|
var SupportedScopes = []string{
|
|
"repo",
|
|
"read:org",
|
|
"read:user",
|
|
"user:email",
|
|
"read:packages",
|
|
"write:packages",
|
|
"read:project",
|
|
"project",
|
|
"gist",
|
|
"notifications",
|
|
"workflow",
|
|
"codespace",
|
|
}
|
|
|
|
// Config holds the OAuth configuration for the MCP server.
|
|
type Config struct {
|
|
// BaseURL is the publicly accessible URL where this server is hosted.
|
|
// This is used to construct the OAuth resource URL.
|
|
BaseURL string
|
|
|
|
// AuthorizationServer is the OAuth authorization server URL.
|
|
// Defaults to GitHub's OAuth server if not specified.
|
|
AuthorizationServer string
|
|
|
|
// ResourcePath is the externally visible base path for the MCP server (e.g., "/mcp").
|
|
// This is used to restore the original path when a proxy strips a base path before forwarding.
|
|
// If empty, requests are treated as already using the external path.
|
|
ResourcePath string
|
|
|
|
// TrustProxyHeaders indicates whether X-Forwarded-Host and X-Forwarded-Proto
|
|
// should be honored when deriving the effective host and scheme for OAuth
|
|
// resource URLs. This must only be enabled when the server is deployed
|
|
// behind a trusted proxy that sets these headers; otherwise an untrusted
|
|
// client can influence the OAuth resource metadata URL advertised to MCP
|
|
// clients. When BaseURL is set, it always takes precedence and these
|
|
// headers are unused.
|
|
TrustProxyHeaders bool
|
|
}
|
|
|
|
// AuthHandler handles OAuth-related HTTP endpoints.
|
|
type AuthHandler struct {
|
|
cfg *Config
|
|
apiHost utils.APIHostResolver
|
|
}
|
|
|
|
// NewAuthHandler creates a new OAuth auth handler.
|
|
func NewAuthHandler(cfg *Config, apiHost utils.APIHostResolver) (*AuthHandler, error) {
|
|
if cfg == nil {
|
|
cfg = &Config{}
|
|
}
|
|
|
|
if apiHost == nil {
|
|
var err error
|
|
apiHost, err = utils.NewAPIHost("https://api.github.com")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create default API host: %w", err)
|
|
}
|
|
}
|
|
|
|
return &AuthHandler{
|
|
cfg: cfg,
|
|
apiHost: apiHost,
|
|
}, nil
|
|
}
|
|
|
|
// routePatterns defines the route patterns for OAuth protected resource metadata.
|
|
var routePatterns = []string{
|
|
"", // Root: /.well-known/oauth-protected-resource
|
|
"/readonly", // Read-only mode
|
|
"/insiders", // Insiders mode
|
|
"/x/{toolset}",
|
|
"/x/{toolset}/readonly",
|
|
}
|
|
|
|
// RegisterRoutes registers the OAuth protected resource metadata routes.
|
|
func (h *AuthHandler) RegisterRoutes(r chi.Router) {
|
|
for _, pattern := range routePatterns {
|
|
for _, route := range h.routesForPattern(pattern) {
|
|
path := OAuthProtectedResourcePrefix + route
|
|
r.Handle(path, h.metadataHandler())
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) metadataHandler() http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
resourcePath := resolveResourcePath(
|
|
strings.TrimPrefix(r.URL.Path, OAuthProtectedResourcePrefix),
|
|
h.cfg.ResourcePath,
|
|
)
|
|
resourceURL := h.buildResourceURL(r, resourcePath)
|
|
|
|
var authorizationServerURL string
|
|
if h.cfg.AuthorizationServer != "" {
|
|
authorizationServerURL = h.cfg.AuthorizationServer
|
|
} else {
|
|
authURL, err := h.apiHost.AuthorizationServerURL(ctx)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to resolve authorization server URL: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
authorizationServerURL = authURL.String()
|
|
}
|
|
|
|
metadata := &oauthex.ProtectedResourceMetadata{
|
|
Resource: resourceURL,
|
|
AuthorizationServers: []string{authorizationServerURL},
|
|
ResourceName: "GitHub MCP Server",
|
|
ScopesSupported: SupportedScopes,
|
|
BearerMethodsSupported: []string{"header"},
|
|
}
|
|
|
|
auth.ProtectedResourceMetadataHandler(metadata).ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// routesForPattern generates route variants for a given pattern.
|
|
// GitHub strips the /mcp prefix before forwarding, so we register both variants:
|
|
// - With /mcp prefix: for direct access or when GitHub doesn't strip
|
|
// - Without /mcp prefix: for when GitHub has stripped the prefix
|
|
func (h *AuthHandler) routesForPattern(pattern string) []string {
|
|
basePaths := []string{""}
|
|
if basePath := normalizeBasePath(h.cfg.ResourcePath); basePath != "" {
|
|
basePaths = append(basePaths, basePath)
|
|
} else {
|
|
basePaths = append(basePaths, "/mcp")
|
|
}
|
|
|
|
routes := make([]string, 0, len(basePaths)*2)
|
|
for _, basePath := range basePaths {
|
|
routes = append(routes, joinRoute(basePath, pattern))
|
|
routes = append(routes, joinRoute(basePath, pattern)+"/")
|
|
}
|
|
|
|
return routes
|
|
}
|
|
|
|
// resolveResourcePath returns the externally visible resource path,
|
|
// restoring the configured base path when proxies strip it before forwarding.
|
|
func resolveResourcePath(path, basePath string) string {
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
base := normalizeBasePath(basePath)
|
|
if base == "" {
|
|
return path
|
|
}
|
|
if path == "/" {
|
|
return base
|
|
}
|
|
if path == base || strings.HasPrefix(path, base+"/") {
|
|
return path
|
|
}
|
|
return base + path
|
|
}
|
|
|
|
// ResolveResourcePath returns the externally visible resource path for a request.
|
|
// Exported for use by middleware.
|
|
func ResolveResourcePath(r *http.Request, cfg *Config) string {
|
|
basePath := ""
|
|
if cfg != nil {
|
|
basePath = cfg.ResourcePath
|
|
}
|
|
return resolveResourcePath(r.URL.Path, basePath)
|
|
}
|
|
|
|
// buildResourceURL constructs the full resource URL for OAuth metadata.
|
|
func (h *AuthHandler) buildResourceURL(r *http.Request, resourcePath string) string {
|
|
host, scheme := GetEffectiveHostAndScheme(r, h.cfg)
|
|
baseURL := fmt.Sprintf("%s://%s", scheme, host)
|
|
if h.cfg.BaseURL != "" {
|
|
baseURL = strings.TrimSuffix(h.cfg.BaseURL, "/")
|
|
}
|
|
if resourcePath == "" {
|
|
resourcePath = "/"
|
|
}
|
|
if !strings.HasPrefix(resourcePath, "/") {
|
|
resourcePath = "/" + resourcePath
|
|
}
|
|
return baseURL + resourcePath
|
|
}
|
|
|
|
// GetEffectiveHostAndScheme returns the effective host and scheme for a request.
|
|
//
|
|
// X-Forwarded-Host and X-Forwarded-Proto are only honored when cfg.TrustProxyHeaders
|
|
// is true. Without that opt-in, an untrusted client could otherwise influence the
|
|
// OAuth resource metadata URL advertised to MCP clients.
|
|
func GetEffectiveHostAndScheme(r *http.Request, cfg *Config) (host, scheme string) { //nolint:revive
|
|
trustProxy := cfg != nil && cfg.TrustProxyHeaders
|
|
|
|
if trustProxy {
|
|
if fh := r.Header.Get(headers.ForwardedHostHeader); fh != "" {
|
|
host = fh
|
|
}
|
|
}
|
|
if host == "" {
|
|
host = r.Host
|
|
}
|
|
if host == "" {
|
|
host = "localhost"
|
|
}
|
|
|
|
if trustProxy {
|
|
if fp := r.Header.Get(headers.ForwardedProtoHeader); fp != "" {
|
|
scheme = strings.ToLower(fp)
|
|
}
|
|
}
|
|
if scheme == "" {
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
} else {
|
|
scheme = "http"
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// BuildResourceMetadataURL constructs the full URL to the OAuth protected resource metadata endpoint.
|
|
func BuildResourceMetadataURL(r *http.Request, cfg *Config, resourcePath string) string {
|
|
host, scheme := GetEffectiveHostAndScheme(r, cfg)
|
|
suffix := ""
|
|
if resourcePath != "" && resourcePath != "/" {
|
|
if !strings.HasPrefix(resourcePath, "/") {
|
|
suffix = "/" + resourcePath
|
|
} else {
|
|
suffix = resourcePath
|
|
}
|
|
}
|
|
if cfg != nil && cfg.BaseURL != "" {
|
|
return strings.TrimSuffix(cfg.BaseURL, "/") + OAuthProtectedResourcePrefix + suffix
|
|
}
|
|
return fmt.Sprintf("%s://%s%s%s", scheme, host, OAuthProtectedResourcePrefix, suffix)
|
|
}
|
|
|
|
func normalizeBasePath(path string) string {
|
|
trimmed := strings.TrimSpace(path)
|
|
if trimmed == "" || trimmed == "/" {
|
|
return ""
|
|
}
|
|
if !strings.HasPrefix(trimmed, "/") {
|
|
trimmed = "/" + trimmed
|
|
}
|
|
return strings.TrimSuffix(trimmed, "/")
|
|
}
|
|
|
|
func joinRoute(basePath, pattern string) string {
|
|
if basePath == "" {
|
|
return pattern
|
|
}
|
|
if pattern == "" {
|
|
return basePath
|
|
}
|
|
if before, ok := strings.CutSuffix(basePath, "/"); ok {
|
|
return before + pattern
|
|
}
|
|
return basePath + pattern
|
|
}
|