feat: server-level GitHub machine credential for authenticated requests (#109)
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:
2026-08-10 21:42:39 +10:00
committed by BenVincent
parent e24c35f534
commit 109ba2ce27
12 changed files with 968 additions and 12 deletions
+199
View File
@@ -0,0 +1,199 @@
package githubauth
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
const (
defaultAPIBase = "https://api.github.com"
// jwtLifetime is how long the app JWT is valid. GitHub caps it at 10 minutes;
// 9 leaves headroom for clock skew.
jwtLifetime = 9 * time.Minute
// jwtBackdate backdates iat to tolerate the app server's clock running behind
// GitHub's, which otherwise rejects the JWT.
jwtBackdate = 60 * time.Second
// refreshSkew refreshes the installation token this long before it expires so
// a request never races an expiry.
refreshSkew = 5 * time.Minute
)
type httpDoer interface {
Do(*http.Request) (*http.Response, error)
}
// appCredential mints installation access tokens for a GitHub App. It signs a
// short-lived RS256 JWT with the app private key, exchanges it for a ~1h
// installation token, caches that token, and refreshes it shortly before expiry.
// Refreshes are single-flighted by holding the mutex across the exchange, so
// concurrent callers coalesce onto one HTTP request and reuse the cached token.
type appCredential struct {
appID string
installationID string
key *rsa.PrivateKey
apiBase string
client httpDoer
mu sync.Mutex
token string
expiry time.Time
}
func newAppCredential(opts Options) (*appCredential, error) {
if opts.AppID == "" {
return nil, errors.New("github app: GITHUB_APP_ID is required")
}
if opts.InstallationID == "" {
return nil, errors.New("github app: GITHUB_APP_INSTALLATION_ID is required")
}
pemBytes, err := loadPrivateKeyPEM(opts)
if err != nil {
return nil, err
}
key, err := parseRSAPrivateKey(pemBytes)
if err != nil {
return nil, err
}
apiBase := opts.apiBaseURL
if apiBase == "" {
apiBase = defaultAPIBase
}
client := opts.httpClient
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
return &appCredential{
appID: opts.AppID,
installationID: opts.InstallationID,
key: key,
apiBase: strings.TrimRight(apiBase, "/"),
client: client,
}, nil
}
// Token returns a cached installation token, refreshing it under a single-flight
// lock when it is missing or within refreshSkew of expiry.
func (a *appCredential) Token(ctx context.Context) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.token != "" && time.Now().Before(a.expiry.Add(-refreshSkew)) {
return a.token, nil
}
if err := a.refreshLocked(ctx); err != nil {
return "", err
}
return a.token, nil
}
func (a *appCredential) refreshLocked(ctx context.Context) error {
jwt, err := mintJWT(a.appID, a.key, time.Now())
if err != nil {
return fmt.Errorf("github app: mint jwt: %w", err)
}
u := fmt.Sprintf("%s/app/installations/%s/access_tokens", a.apiBase, a.installationID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+jwt)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := a.client.Do(req)
if err != nil {
return fmt.Errorf("github app: token exchange: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
// Never echo the body verbatim — it can contain sensitive material.
return fmt.Errorf("github app: token exchange status %d", resp.StatusCode)
}
var out struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
if err := json.Unmarshal(body, &out); err != nil {
return fmt.Errorf("github app: decode token response: %w", err)
}
if out.Token == "" {
return errors.New("github app: token exchange returned an empty token")
}
a.token = out.Token
a.expiry = out.ExpiresAt
if a.expiry.IsZero() {
// Defensive: assume the documented ~1h lifetime if GitHub omits it.
a.expiry = time.Now().Add(time.Hour)
}
return nil
}
// mintJWT builds and RS256-signs a GitHub App JWT (iss=app id, backdated iat,
// ≤10m exp) using stdlib crypto — no third-party JWT dependency.
func mintJWT(appID string, key *rsa.PrivateKey, now time.Time) (string, error) {
header := map[string]string{"alg": "RS256", "typ": "JWT"}
claims := map[string]any{
"iat": now.Add(-jwtBackdate).Unix(),
"exp": now.Add(jwtLifetime).Unix(),
"iss": appID,
}
hb, err := json.Marshal(header)
if err != nil {
return "", err
}
cb, err := json.Marshal(claims)
if err != nil {
return "", err
}
signingInput := b64url(hb) + "." + b64url(cb)
digest := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return "", err
}
return signingInput + "." + b64url(sig), nil
}
func b64url(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
// parseRSAPrivateKey accepts PKCS#1 ("RSA PRIVATE KEY") and PKCS#8 ("PRIVATE
// KEY") PEM, covering both GitHub App key export formats.
func parseRSAPrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, errors.New("github app: private key is not valid PEM")
}
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, errors.New("github app: private key is not a supported RSA PKCS#1/PKCS#8 key")
}
rsaKey, ok := keyAny.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("github app: private key is not an RSA key")
}
return rsaKey, nil
}
+207
View File
@@ -0,0 +1,207 @@
package githubauth
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func testRSAKeyPEM(t *testing.T) string {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der := x509.MarshalPKCS1PrivateKey(key)
return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}))
}
// appFixture serves the installation-token exchange endpoint, records requests,
// verifies the presented JWT against the app public key, and returns tokens with
// a controllable expiry.
type appFixture struct {
srv *httptest.Server
pub *rsa.PublicKey
mu sync.Mutex
exchanges int
lastJWT string
expiresAt func() time.Time
tokenSeq int
}
func newAppFixture(t *testing.T, pemKey string) *appFixture {
t.Helper()
block, _ := pem.Decode([]byte(pemKey))
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
t.Fatalf("parse test key: %v", err)
}
f := &appFixture{
pub: &key.PublicKey,
expiresAt: func() time.Time { return time.Now().Add(time.Hour) },
}
mux := http.NewServeMux()
mux.HandleFunc("/app/installations/456/access_tokens", func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
jwt := strings.TrimPrefix(auth, "Bearer ")
f.mu.Lock()
f.exchanges++
f.lastJWT = jwt
f.tokenSeq++
seq := f.tokenSeq
exp := f.expiresAt()
f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{
"token": fmt.Sprintf("ghs_installation_%d", seq),
"expires_at": exp.UTC().Format(time.RFC3339),
})
})
f.srv = httptest.NewServer(mux)
t.Cleanup(f.srv.Close)
return f
}
func (f *appFixture) verifyJWT(t *testing.T) {
t.Helper()
f.mu.Lock()
jwt := f.lastJWT
f.mu.Unlock()
parts := strings.Split(jwt, ".")
if len(parts) != 3 {
t.Fatalf("jwt not three-part: %q", jwt)
}
signingInput := parts[0] + "." + parts[1]
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
t.Fatalf("decode sig: %v", err)
}
digest := sha256.Sum256([]byte(signingInput))
if err := rsa.VerifyPKCS1v15(f.pub, crypto.SHA256, digest[:], sig); err != nil {
t.Fatalf("jwt signature invalid: %v", err)
}
var claims struct {
Iss string `json:"iss"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
}
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatalf("decode claims: %v", err)
}
if claims.Iss != "123" {
t.Fatalf("iss = %q, want 123", claims.Iss)
}
if claims.Exp-claims.Iat > int64((10*time.Minute)/time.Second) {
t.Fatalf("jwt lifetime exceeds 10m: iat=%d exp=%d", claims.Iat, claims.Exp)
}
if claims.Iat > time.Now().Unix() {
t.Fatalf("iat not backdated: %d", claims.Iat)
}
}
func newAppCred(t *testing.T, f *appFixture, pemKey string) *appCredential {
t.Helper()
c, err := newAppCredential(Options{
AppID: "123",
InstallationID: "456",
PrivateKeyPEM: pemKey,
apiBaseURL: f.srv.URL,
httpClient: f.srv.Client(),
})
if err != nil {
t.Fatalf("newAppCredential: %v", err)
}
return c
}
func TestApp_MintsJWTAndExchangesForInstallationToken(t *testing.T) {
pemKey := testRSAKeyPEM(t)
f := newAppFixture(t, pemKey)
c := newAppCred(t, f, pemKey)
tok, err := c.Token(context.Background())
if err != nil {
t.Fatalf("token: %v", err)
}
if tok != "ghs_installation_1" {
t.Fatalf("token = %q, want ghs_installation_1", tok)
}
if f.exchanges != 1 {
t.Fatalf("exchanges = %d, want 1", f.exchanges)
}
f.verifyJWT(t)
}
func TestApp_CachesInstallationToken(t *testing.T) {
pemKey := testRSAKeyPEM(t)
f := newAppFixture(t, pemKey)
c := newAppCred(t, f, pemKey)
for i := 0; i < 5; i++ {
if _, err := c.Token(context.Background()); err != nil {
t.Fatalf("token: %v", err)
}
}
if f.exchanges != 1 {
t.Fatalf("exchanges = %d, want 1 (token should be cached)", f.exchanges)
}
}
func TestApp_RefreshesNearExpiry(t *testing.T) {
pemKey := testRSAKeyPEM(t)
f := newAppFixture(t, pemKey)
// Token expires within refreshSkew, so every call must re-exchange.
f.expiresAt = func() time.Time { return time.Now().Add(2 * time.Minute) }
c := newAppCred(t, f, pemKey)
t1, err := c.Token(context.Background())
if err != nil {
t.Fatalf("token 1: %v", err)
}
t2, err := c.Token(context.Background())
if err != nil {
t.Fatalf("token 2: %v", err)
}
if f.exchanges != 2 {
t.Fatalf("exchanges = %d, want 2 (near-expiry token must refresh)", f.exchanges)
}
if t1 == t2 {
t.Fatalf("expected a fresh token after refresh, both = %q", t1)
}
}
func TestApp_ConcurrentTokenSingleFlights(t *testing.T) {
pemKey := testRSAKeyPEM(t)
f := newAppFixture(t, pemKey)
c := newAppCred(t, f, pemKey)
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := c.Token(context.Background()); err != nil {
t.Errorf("token: %v", err)
}
}()
}
wg.Wait()
if f.exchanges != 1 {
t.Fatalf("exchanges = %d, want 1 (concurrent calls must coalesce)", f.exchanges)
}
}
+106
View File
@@ -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")
}
+77
View File
@@ -0,0 +1,77 @@
package githubauth
import (
"context"
"testing"
)
func TestNew_NoConfigIsAnonymous(t *testing.T) {
c, err := New(Options{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c != nil {
t.Fatalf("expected nil credential when nothing configured, got %T", c)
}
}
func TestNew_TokenMode(t *testing.T) {
c, err := New(Options{Token: "ghp_example"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
tok, err := c.Token(context.Background())
if err != nil {
t.Fatalf("token: %v", err)
}
if tok != "ghp_example" {
t.Fatalf("token = %q, want ghp_example", tok)
}
}
func TestNew_TokenAndAppConflict(t *testing.T) {
_, err := New(Options{Token: "ghp_example", AppID: "123"})
if err == nil {
t.Fatal("expected error when both token and app fields are set")
}
}
func TestNew_PartialAppFailsClosed(t *testing.T) {
cases := map[string]Options{
"app id without key": {AppID: "123", InstallationID: "456"},
"key without app id": {InstallationID: "456", PrivateKeyPEM: testRSAKeyPEM(t)},
"app id without inst": {AppID: "123", PrivateKeyPEM: testRSAKeyPEM(t)},
}
for name, opts := range cases {
t.Run(name, func(t *testing.T) {
if _, err := New(opts); err == nil {
t.Fatalf("expected fail-closed error for %q", name)
}
})
}
}
func TestNew_AppModeParsesKey(t *testing.T) {
c, err := New(Options{
AppID: "123",
InstallationID: "456",
PrivateKeyPEM: testRSAKeyPEM(t),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := c.(*appCredential); !ok {
t.Fatalf("expected *appCredential, got %T", c)
}
}
func TestNew_AppModeRejectsBadKey(t *testing.T) {
_, err := New(Options{
AppID: "123",
InstallationID: "456",
PrivateKeyPEM: "-----BEGIN RSA PRIVATE KEY-----\nnope\n-----END RSA PRIVATE KEY-----",
})
if err == nil {
t.Fatal("expected error for malformed private key")
}
}