feat: server-level GitHub machine credential for authenticated requests
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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user