Files
Sam Morrow 46b8cb63ac Add PAT scope filtering for stdio server
Add the ability to filter tools based on token scopes for PAT users.
This uses an HTTP HEAD request to GitHub's API to discover token scopes.

New components:
- pkg/scopes/filter.go: HasRequiredScopes checks if scopes satisfy tool requirements
- pkg/scopes/fetcher.go: FetchTokenScopes gets scopes via HTTP HEAD to GitHub API
- pkg/github/scope_filter.go: CreateScopeFilter creates inventory.ToolFilter

Integration:
- Add --filter-by-scope flag to stdio command (disabled by default)
- When enabled, fetches token scopes on startup
- Tools requiring unavailable scopes are hidden from tool list
- Gracefully continues without filtering if scope fetch fails (logs warning)

This allows the OSS server to have similar scope-based tool visibility
as the remote server, and the filter logic can be reused by remote server.
2026-01-05 16:05:24 +00:00

126 lines
3.7 KiB
Go

package scopes
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// OAuthScopesHeader is the HTTP response header containing the token's OAuth scopes.
const OAuthScopesHeader = "X-OAuth-Scopes"
// DefaultFetchTimeout is the default timeout for scope fetching requests.
const DefaultFetchTimeout = 10 * time.Second
// FetcherOptions configures the scope fetcher.
type FetcherOptions struct {
// HTTPClient is the HTTP client to use for requests.
// If nil, a default client with DefaultFetchTimeout is used.
HTTPClient *http.Client
// APIHost is the GitHub API host (e.g., "https://api.github.com").
// Defaults to "https://api.github.com" if empty.
APIHost string
}
// Fetcher retrieves token scopes from GitHub's API.
// It uses an HTTP HEAD request to minimize bandwidth since we only need headers.
type Fetcher struct {
client *http.Client
apiHost string
}
// NewFetcher creates a new scope fetcher with the given options.
func NewFetcher(opts FetcherOptions) *Fetcher {
client := opts.HTTPClient
if client == nil {
client = &http.Client{Timeout: DefaultFetchTimeout}
}
apiHost := opts.APIHost
if apiHost == "" {
apiHost = "https://api.github.com"
}
return &Fetcher{
client: client,
apiHost: apiHost,
}
}
// FetchTokenScopes retrieves the OAuth scopes for a token by making an HTTP HEAD
// request to the GitHub API and parsing the X-OAuth-Scopes header.
//
// Returns:
// - []string: List of scopes (empty if no scopes or fine-grained PAT)
// - error: Any HTTP or parsing error
//
// Note: Fine-grained PATs don't return the X-OAuth-Scopes header, so an empty
// slice is returned for those tokens.
func (f *Fetcher) FetchTokenScopes(ctx context.Context, token string) ([]string, error) {
// Use a lightweight endpoint that requires authentication
endpoint, err := url.JoinPath(f.apiHost, "/")
if err != nil {
return nil, fmt.Errorf("failed to construct API URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodHead, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := f.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch scopes: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("invalid or expired token")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return ParseScopeHeader(resp.Header.Get(OAuthScopesHeader)), nil
}
// ParseScopeHeader parses the X-OAuth-Scopes header value into a list of scopes.
// The header contains comma-separated scope names.
// Returns an empty slice for empty or missing header.
func ParseScopeHeader(header string) []string {
if header == "" {
return []string{}
}
parts := strings.Split(header, ",")
scopes := make([]string, 0, len(parts))
for _, part := range parts {
scope := strings.TrimSpace(part)
if scope != "" {
scopes = append(scopes, scope)
}
}
return scopes
}
// FetchTokenScopes is a convenience function that creates a default fetcher
// and fetches the token scopes.
func FetchTokenScopes(ctx context.Context, token string) ([]string, error) {
return NewFetcher(FetcherOptions{}).FetchTokenScopes(ctx, token)
}
// FetchTokenScopesWithHost is a convenience function that creates a fetcher
// for a specific API host and fetches the token scopes.
func FetchTokenScopesWithHost(ctx context.Context, token, apiHost string) ([]string, error) {
return NewFetcher(FetcherOptions{APIHost: apiHost}).FetchTokenScopes(ctx, token)
}