ea4e3960b8
Keep PEM loading and installation-token provider construction at the CLI leaf, then pass a generic refreshing token provider through the existing HTTP transports. Rebase the feature onto current main and keep the HTTP command unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 646357dd-c89f-4973-9a5c-e6c5fc18818c
222 lines
6.3 KiB
Go
222 lines
6.3 KiB
Go
// Package githubapp provides GitHub App installation access tokens.
|
|
package githubapp
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
const (
|
|
jwtLifetime = 9 * time.Minute
|
|
clockSkew = time.Minute
|
|
refreshBuffer = 5 * time.Minute
|
|
httpTimeout = 30 * time.Second
|
|
)
|
|
|
|
// Config describes a GitHub App installation used for server-to-server auth.
|
|
type Config struct {
|
|
// AppID is used as the JWT issuer. GitHub accepts an app ID or client ID.
|
|
AppID string
|
|
|
|
// InstallationID identifies the installation whose access token is minted.
|
|
InstallationID string
|
|
|
|
// PrivateKeyPEM is the RSA key used to sign app JWTs.
|
|
PrivateKeyPEM []byte
|
|
|
|
// BaseRESTURL is the REST API base, e.g. https://api.github.com/ for
|
|
// github.com or https://HOST/api/v3/ for GitHub Enterprise Server.
|
|
BaseRESTURL string
|
|
}
|
|
|
|
func (c Config) validate() error {
|
|
switch {
|
|
case c.AppID == "":
|
|
return errors.New("GitHub App ID or client ID is required (GITHUB_APP_ID)")
|
|
case c.InstallationID == "":
|
|
return errors.New("GitHub App installation ID is required (GITHUB_APP_INSTALLATION_ID)")
|
|
case len(c.PrivateKeyPEM) == 0:
|
|
return errors.New("GitHub App private key is required (GITHUB_APP_PRIVATE_KEY_PATH or GITHUB_APP_PRIVATE_KEY)")
|
|
case c.BaseRESTURL == "":
|
|
return errors.New("GitHub App REST base URL is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
|
|
block, _ := pem.Decode(pemBytes)
|
|
if block == nil {
|
|
return nil, errors.New("no PEM block found in private key")
|
|
}
|
|
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
|
return key, nil
|
|
}
|
|
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing private key (want PKCS#1 or PKCS#8 RSA): %w", err)
|
|
}
|
|
key, ok := parsed.(*rsa.PrivateKey)
|
|
if !ok {
|
|
return nil, fmt.Errorf("private key is %T, want an RSA key", parsed)
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
func mintJWT(appID string, privateKey *rsa.PrivateKey, now time.Time) (string, error) {
|
|
header := map[string]string{"alg": "RS256", "typ": "JWT"}
|
|
claims := map[string]any{
|
|
"iat": now.Add(-clockSkew).Unix(),
|
|
"exp": now.Add(jwtLifetime).Unix(),
|
|
"iss": appID,
|
|
}
|
|
|
|
headerJSON, err := json.Marshal(header)
|
|
if err != nil {
|
|
return "", fmt.Errorf("encoding JWT header: %w", err)
|
|
}
|
|
claimsJSON, err := json.Marshal(claims)
|
|
if err != nil {
|
|
return "", fmt.Errorf("encoding JWT claims: %w", err)
|
|
}
|
|
|
|
signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." +
|
|
base64.RawURLEncoding.EncodeToString(claimsJSON)
|
|
|
|
digest := sha256.Sum256([]byte(signingInput))
|
|
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, digest[:])
|
|
if err != nil {
|
|
return "", fmt.Errorf("signing JWT: %w", err)
|
|
}
|
|
|
|
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil
|
|
}
|
|
|
|
type installationTokenSource struct {
|
|
cfg Config
|
|
privateKey *rsa.PrivateKey
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func newInstallationTokenSource(cfg Config, privateKey *rsa.PrivateKey, httpClient *http.Client) *installationTokenSource {
|
|
if httpClient == nil {
|
|
httpClient = &http.Client{Timeout: httpTimeout}
|
|
}
|
|
return &installationTokenSource{cfg: cfg, privateKey: privateKey, httpClient: httpClient}
|
|
}
|
|
|
|
func (s *installationTokenSource) Token() (*oauth2.Token, error) {
|
|
jwt, err := mintJWT(s.cfg.AppID, s.privateKey, time.Now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
endpoint, err := url.JoinPath(s.cfg.BaseRESTURL, "app", "installations", s.cfg.InstallationID, "access_tokens")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("building installation token URL: %w", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating installation token request: %w", 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 := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("requesting installation token: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
snippet, readErr := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("installation token request failed: %s (reading response: %w)", resp.Status, readErr)
|
|
}
|
|
return nil, fmt.Errorf("installation token request failed: %s: %s", resp.Status, strings.TrimSpace(string(snippet)))
|
|
}
|
|
|
|
var body struct {
|
|
Token string `json:"token"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
return nil, fmt.Errorf("decoding installation token response: %w", err)
|
|
}
|
|
if body.Token == "" {
|
|
return nil, errors.New("installation token response did not contain a token")
|
|
}
|
|
if body.ExpiresAt.IsZero() {
|
|
return nil, errors.New("installation token response did not contain an expiry")
|
|
}
|
|
return &oauth2.Token{
|
|
AccessToken: body.Token,
|
|
TokenType: "token",
|
|
Expiry: body.ExpiresAt.Add(-refreshBuffer),
|
|
}, nil
|
|
}
|
|
|
|
// Provider caches and refreshes GitHub App installation access tokens.
|
|
type Provider struct {
|
|
source oauth2.TokenSource
|
|
logger *slog.Logger
|
|
|
|
mu sync.Mutex
|
|
errLogged bool
|
|
}
|
|
|
|
func NewProvider(cfg Config, logger *slog.Logger) (*Provider, error) {
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
privateKey, err := parsePrivateKey(cfg.PrivateKeyPEM)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid GitHub App private key: %w", err)
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
source := oauth2.ReuseTokenSource(nil, newInstallationTokenSource(cfg, privateKey, nil))
|
|
return &Provider{source: source, logger: logger}, nil
|
|
}
|
|
|
|
// AccessToken returns a cached token or refreshes it before expiry.
|
|
func (p *Provider) AccessToken() string {
|
|
tok, err := p.source.Token()
|
|
if err != nil {
|
|
p.mu.Lock()
|
|
if !p.errLogged {
|
|
p.errLogged = true
|
|
p.logger.Error("failed to obtain GitHub App installation token", "error", err)
|
|
}
|
|
p.mu.Unlock()
|
|
return ""
|
|
}
|
|
p.mu.Lock()
|
|
p.errLogged = false
|
|
p.mu.Unlock()
|
|
return tok.AccessToken
|
|
}
|