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