Files
github--github-mcp-server/internal/githubapp/githubapp.go
T
Sam Morrow ea4e3960b8 refactor(auth): isolate GitHub App auth to stdio startup
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
2026-07-22 18:51:35 +02:00

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
}