109ba2ce27
ci/woodpecker/tag/docker Pipeline was successful
## Why Anonymous GitHub is capped at 60 requests/hour and cannot read private repositories. A machine credential usable by a free (non-enterprise) account is needed to lift the request budget to ~5000/hr and to read private-repo release assets. Builds on the background syncer (#108, now merged to `master`); this diff is the auth changes only. ## How - Add `internal/githubauth`: a process-wide GitHub credential delivered via env/secret, applied by default to every outbound GitHub request (releases scan, ranged asset-header GETs, and the generic-github byte proxy for private assets). - Support two modes: - **PAT** — `GITHUB_TOKEN` sent as `Authorization: Bearer <token>`. - **GitHub App** — `GITHUB_APP_ID` + `GITHUB_APP_INSTALLATION_ID` + private key (`GITHUB_APP_PRIVATE_KEY` inline PEM or `GITHUB_APP_PRIVATE_KEY_PATH`). Mint a short-lived RS256 JWT with stdlib `crypto/rsa` (no new dependency), exchange it at `POST /app/installations/{id}/access_tokens` for a ~1h installation token, cache it, and single-flight a refresh a few minutes before expiry. - Inject at the two GitHub call paths: the rpm github provider header builder (releases + ranged fetches) and the generic provider `AuthHeaders` (byte proxy, github.com hosts only; the pre-signed `objects.githubusercontent.com` redirect deliberately gets no Authorization). - Honor precedence: a remote's own `username`/`password` overrides the server credential; no credential configured stays anonymous (current behavior). - Fail closed at startup on partial App configuration (e.g. App id without a private key); a token-and-App conflict is also rejected. - Never persist the credential to the DB, return it from an API, or log it (token-exchange failures never echo the response body). - Read config 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. ## Rate limit Authenticated requests share the syncer's single global limiter — no second limiter is added. A token raises the effective GitHub ceiling (~5000/hr vs ~60/hr), so the limiter defaults stay safe. ## Tests `internal/githubauth` and `internal/provider/{rpm,generic}`: - PAT attaches the correct `Authorization` header to releases + asset-header requests. - App mints a valid RS256 JWT (verified against the app public key), exchanges it at a mocked endpoint, reuses the cached token without re-exchanging, refreshes near expiry, and single-flights concurrent callers. - Per-remote credential overrides the server credential (rpm + generic). - No credential → no `Authorization` header, requests still succeed anonymously. - ETag/304 flow still works with auth attached. - The credential does not appear in a remote's serialized JSON. - Config validation: no-config is anonymous; partial App config and token/App conflict both error. Verified fail-before/pass-after for the injection tests. `gofmt -l`, `go build ./...`, `go vet ./...`, `go test ./...` all clean (26 packages). Reviewed-on: #109 Co-authored-by: Ben Vincent <ben@unkin.net> Co-committed-by: Ben Vincent <ben@unkin.net>
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")
|
|
}
|