feat: server-level GitHub machine credential for authenticated requests (#109)
ci/woodpecker/tag/docker Pipeline was successful
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>
This commit was merged in pull request #109.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
// 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")
|
||||
}
|
||||
Reference in New Issue
Block a user