package github import ( "context" "fmt" "net/http" "net/url" "strings" "time" "github.com/github/github-mcp-server/pkg/scopes" ) type apiHost struct { baseRESTURL *url.URL graphqlURL *url.URL uploadURL *url.URL rawURL *url.URL } func newDotcomHost() (apiHost, error) { baseRestURL, err := url.Parse("https://api.github.com/") if err != nil { return apiHost{}, fmt.Errorf("failed to parse dotcom REST URL: %w", err) } gqlURL, err := url.Parse("https://api.github.com/graphql") if err != nil { return apiHost{}, fmt.Errorf("failed to parse dotcom GraphQL URL: %w", err) } uploadURL, err := url.Parse("https://uploads.github.com") if err != nil { return apiHost{}, fmt.Errorf("failed to parse dotcom Upload URL: %w", err) } rawURL, err := url.Parse("https://raw.githubusercontent.com/") if err != nil { return apiHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err) } return apiHost{ baseRESTURL: baseRestURL, graphqlURL: gqlURL, uploadURL: uploadURL, rawURL: rawURL, }, nil } func newGHECHost(hostname string) (apiHost, error) { u, err := url.Parse(hostname) if err != nil { return apiHost{}, fmt.Errorf("failed to parse GHEC URL: %w", err) } // Unsecured GHEC would be an error if u.Scheme == "http" { return apiHost{}, fmt.Errorf("GHEC URL must be HTTPS") } restURL, err := url.Parse(fmt.Sprintf("https://api.%s/", u.Hostname())) if err != nil { return apiHost{}, fmt.Errorf("failed to parse GHEC REST URL: %w", err) } gqlURL, err := url.Parse(fmt.Sprintf("https://api.%s/graphql", u.Hostname())) if err != nil { return apiHost{}, fmt.Errorf("failed to parse GHEC GraphQL URL: %w", err) } uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s", u.Hostname())) if err != nil { return apiHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err) } rawURL, err := url.Parse(fmt.Sprintf("https://raw.%s/", u.Hostname())) if err != nil { return apiHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err) } return apiHost{ baseRESTURL: restURL, graphqlURL: gqlURL, uploadURL: uploadURL, rawURL: rawURL, }, nil } func newGHESHost(hostname string) (apiHost, error) { u, err := url.Parse(hostname) if err != nil { return apiHost{}, fmt.Errorf("failed to parse GHES URL: %w", err) } restURL, err := url.Parse(fmt.Sprintf("%s://%s/api/v3/", u.Scheme, u.Hostname())) if err != nil { return apiHost{}, fmt.Errorf("failed to parse GHES REST URL: %w", err) } gqlURL, err := url.Parse(fmt.Sprintf("%s://%s/api/graphql", u.Scheme, u.Hostname())) if err != nil { return apiHost{}, fmt.Errorf("failed to parse GHES GraphQL URL: %w", err) } // Check if subdomain isolation is enabled // See https://docs.github.com/en/enterprise-server@3.17/admin/configuring-settings/hardening-security-for-your-enterprise/enabling-subdomain-isolation#about-subdomain-isolation hasSubdomainIsolation := checkSubdomainIsolation(u.Scheme, u.Hostname()) var uploadURL *url.URL if hasSubdomainIsolation { // With subdomain isolation: https://uploads.hostname/ uploadURL, err = url.Parse(fmt.Sprintf("%s://uploads.%s/", u.Scheme, u.Hostname())) } else { // Without subdomain isolation: https://hostname/api/uploads/ uploadURL, err = url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, u.Hostname())) } if err != nil { return apiHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err) } var rawURL *url.URL if hasSubdomainIsolation { // With subdomain isolation: https://raw.hostname/ rawURL, err = url.Parse(fmt.Sprintf("%s://raw.%s/", u.Scheme, u.Hostname())) } else { // Without subdomain isolation: https://hostname/raw/ rawURL, err = url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, u.Hostname())) } if err != nil { return apiHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err) } return apiHost{ baseRESTURL: restURL, graphqlURL: gqlURL, uploadURL: uploadURL, rawURL: rawURL, }, nil } // checkSubdomainIsolation detects if GitHub Enterprise Server has subdomain isolation enabled // by attempting to ping the raw./_ping endpoint on the subdomain. The raw subdomain must always exist for subdomain isolation. func checkSubdomainIsolation(scheme, hostname string) bool { subdomainURL := fmt.Sprintf("%s://raw.%s/_ping", scheme, hostname) client := &http.Client{ Timeout: 5 * time.Second, // Don't follow redirects - we just want to check if the endpoint exists //nolint:revive // parameters are required by http.Client.CheckRedirect signature CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } resp, err := client.Get(subdomainURL) if err != nil { return false } defer resp.Body.Close() return resp.StatusCode == http.StatusOK } // ParseAPIHost parses the given host string and returns the appropriate apiHost configuration. func ParseAPIHost(s string) (apiHost, error) { if s == "" { return newDotcomHost() } u, err := url.Parse(s) if err != nil { return apiHost{}, fmt.Errorf("could not parse host as URL: %s", s) } if u.Scheme == "" { return apiHost{}, fmt.Errorf("host must have a scheme (http or https): %s", s) } if strings.HasSuffix(u.Hostname(), "github.com") { return newDotcomHost() } if strings.HasSuffix(u.Hostname(), "ghe.com") { return newGHECHost(s) } return newGHESHost(s) } // FetchTokenScopesForHost fetches the OAuth scopes for a token from the GitHub API. // It constructs the appropriate API host URL based on the configured host. func FetchTokenScopesForHost(ctx context.Context, token, host string) ([]string, error) { apiHost, err := ParseAPIHost(host) if err != nil { return nil, fmt.Errorf("failed to parse API host: %w", err) } fetcher := scopes.NewFetcher(scopes.FetcherOptions{ APIHost: apiHost.baseRESTURL.String(), }) return fetcher.FetchTokenScopes(ctx, token) }