8ced48901f
Anonymous GitHub is capped at 60 requests/hour and cannot read private repositories, so a machine credential usable by a free (non-enterprise) account is needed to lift the request budget and reach private release assets. - Add internal/githubauth: a process-wide credential delivered via env/secret, applied by default to every outbound GitHub request. - Support two modes: a Personal Access Token sent as `Authorization: Bearer`, and a GitHub App that mints a short-lived RS256 JWT (stdlib crypto, no new dependency), exchanges it for a ~1h installation token, caches it, and single-flights a refresh a few minutes before expiry. - Inject the credential at the two GitHub call paths: the rpm github provider (releases scan + ranged asset-header GETs) and the generic byte proxy (private release-asset downloads for github.com hosts). - Honor precedence: a remote's own username/password overrides the server credential; no credential configured stays anonymous. - Fail closed at startup on partial App configuration; never persist the credential to the DB, return it from an API, or log it. - Read GITHUB_TOKEN / GITHUB_APP_ID / GITHUB_APP_INSTALLATION_ID / GITHUB_APP_PRIVATE_KEY[_PATH] via the existing getenv convention. - Document PAT vs App setup, the free-account fine-grained PAT scopes (Contents:read + Metadata:read), precedence, and the rate-limit implication.
107 lines
3.5 KiB
Go
107 lines
3.5 KiB
Go
// Package githubauth provides the process-wide GitHub machine credential used to
|
|
// authenticate every outbound GitHub request (releases scan, ranged asset header
|
|
// fetches, and the generic-github byte proxy for private assets). The credential
|
|
// is delivered via env/secret only — it is never stored per-remote in the DB,
|
|
// never returned by any API, and never logged.
|
|
package githubauth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Credential yields a bearer token for GitHub requests. Token may block to mint
|
|
// or refresh (the GitHub App path); an empty string means "no auth", which only
|
|
// happens when no credential is configured.
|
|
type Credential interface {
|
|
Token(ctx context.Context) (string, error)
|
|
}
|
|
|
|
// Options is the raw, env-sourced auth configuration. Exactly one mode may be
|
|
// configured: a static token, or a GitHub App (id + installation id + private
|
|
// key). Partial App configuration is an error (fail closed); no fields at all is
|
|
// fine and yields a nil credential (anonymous, current behavior).
|
|
type Options struct {
|
|
// Token is a Personal Access Token (fine-grained or classic) sent verbatim
|
|
// as "Authorization: Bearer <token>".
|
|
Token string
|
|
|
|
// GitHub App fields. PrivateKeyPEM and PrivateKeyPath are alternatives; the
|
|
// inline PEM wins when both are set.
|
|
AppID string
|
|
InstallationID string
|
|
PrivateKeyPEM string
|
|
PrivateKeyPath string
|
|
|
|
// apiBaseURL overrides https://api.github.com for tests. Empty uses the real
|
|
// endpoint. httpClient likewise overrides the default client for tests.
|
|
apiBaseURL string
|
|
httpClient httpDoer
|
|
}
|
|
|
|
// New builds the process credential from options, validating that auth is either
|
|
// fully configured or fully absent. It returns (nil, nil) when nothing is set.
|
|
func New(opts Options) (Credential, error) {
|
|
hasToken := opts.Token != ""
|
|
hasAppField := opts.AppID != "" || opts.InstallationID != "" ||
|
|
opts.PrivateKeyPEM != "" || opts.PrivateKeyPath != ""
|
|
|
|
switch {
|
|
case !hasToken && !hasAppField:
|
|
return nil, nil // no auth configured — anonymous is fine
|
|
case hasToken && hasAppField:
|
|
return nil, errors.New("github auth: both a token and GitHub App fields are set; configure exactly one")
|
|
case hasToken:
|
|
return staticToken{token: opts.Token}, nil
|
|
default:
|
|
return newAppCredential(opts)
|
|
}
|
|
}
|
|
|
|
// staticToken is a fixed PAT credential.
|
|
type staticToken struct{ token string }
|
|
|
|
func (s staticToken) Token(context.Context) (string, error) { return s.token, nil }
|
|
|
|
// server is the process-wide credential set once at startup. A nil value means
|
|
// no server credential (anonymous). Access is guarded so a late SetServer in a
|
|
// test is race-free.
|
|
var (
|
|
serverMu sync.RWMutex
|
|
server Credential
|
|
)
|
|
|
|
// SetServer installs the process credential. Call once during startup.
|
|
func SetServer(c Credential) {
|
|
serverMu.Lock()
|
|
server = c
|
|
serverMu.Unlock()
|
|
}
|
|
|
|
// Server returns the process credential, or nil if none is configured.
|
|
func Server() Credential {
|
|
serverMu.RLock()
|
|
defer serverMu.RUnlock()
|
|
return server
|
|
}
|
|
|
|
// loadPrivateKeyPEM resolves the App private key bytes from the inline PEM or a
|
|
// file path, without ever returning the key material in an error message.
|
|
func loadPrivateKeyPEM(opts Options) ([]byte, error) {
|
|
if strings.TrimSpace(opts.PrivateKeyPEM) != "" {
|
|
return []byte(opts.PrivateKeyPEM), nil
|
|
}
|
|
if opts.PrivateKeyPath != "" {
|
|
b, err := os.ReadFile(opts.PrivateKeyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("github app: read private key file: %w", err)
|
|
}
|
|
return b, nil
|
|
}
|
|
return nil, errors.New("github app: no private key configured")
|
|
}
|