Files
artifactapi/internal/githubauth/credential.go
T
unkinben 109ba2ce27
ci/woodpecker/tag/docker Pipeline was successful
feat: server-level GitHub machine credential for authenticated requests (#109)
## 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>
2026-08-10 21:42:39 +10:00

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