Files

369 lines
12 KiB
Go

package github
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const maxGitHubAppResponseSize = 4 * 1024 * 1024
var ErrGitHubAppResponseTooLarge = errors.New("GitHub App response exceeds the allowed size")
// PermissionLevel is a GitHub App repository permission access level.
type PermissionLevel string
const (
PermissionRead PermissionLevel = "read"
PermissionWrite PermissionLevel = "write"
)
// InstallationPermissions is the permission subset requested for an
// installation access token.
type InstallationPermissions map[string]PermissionLevel
// GitHubAppCapability is an operation-level capability derived from the
// permissions GitHub reports for an installation or token.
type GitHubAppCapability string
const (
CapabilityRepositoryRead GitHubAppCapability = "repository_read"
CapabilityGitRead GitHubAppCapability = "git_read"
CapabilityGitWrite GitHubAppCapability = "git_write"
CapabilityPullRequestRead GitHubAppCapability = "pull_request_read"
CapabilityPullRequestWrite GitHubAppCapability = "pull_request_write"
CapabilityIssueRead GitHubAppCapability = "issue_read"
CapabilityIssueWrite GitHubAppCapability = "issue_write"
CapabilityChecksRead GitHubAppCapability = "checks_read"
CapabilityStatusesRead GitHubAppCapability = "statuses_read"
CapabilityActionsRead GitHubAppCapability = "actions_read"
CapabilityBranchProtectionRead GitHubAppCapability = "branch_protection_read"
CapabilityMembersRead GitHubAppCapability = "members_read"
CapabilityWorkflowsWrite GitHubAppCapability = "workflows_write"
)
// InstallationToken is a short-lived token minted for one App installation.
type InstallationToken struct {
Token string
ExpiresAt time.Time
Permissions InstallationPermissions
Principal TokenPrincipal
}
// AppInstallation is verified installation metadata returned by GitHub.
type AppInstallation struct {
ID int64
AccountID int64
AccountLogin string
AccountType string
Permissions InstallationPermissions
SuspendedAt *time.Time
}
type AuthenticatedApp struct {
ID int64
ClientID string
Slug string
Name string
OwnerLogin string
OwnerType string
ExternalURL string
Permissions map[string]string
Events []string
}
type AppWebhookConfig struct {
URL string
ContentType string
InsecureSSL string
}
// AppClient authenticates as the deployment GitHub App.
type AppClient struct {
appID int64
privateKey *rsa.PrivateKey
httpClient *http.Client
baseURL string
now func() time.Time
}
// NewAppClient parses an RSA private key and creates a GitHub App client.
func NewAppClient(appID int64, privateKeyPEM []byte) (*AppClient, error) {
if appID <= 0 {
return nil, fmt.Errorf("GitHub App ID must be positive")
}
privateKey, err := parseAppPrivateKey(privateKeyPEM)
if err != nil {
return nil, fmt.Errorf("parse GitHub App private key: %w", err)
}
return &AppClient{
appID: appID,
privateKey: privateKey,
httpClient: &http.Client{Timeout: 30 * time.Second},
baseURL: githubAPIBase,
now: time.Now,
}, nil
}
func parseAppPrivateKey(keyPEM []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(keyPEM)
if block == nil {
return nil, errors.New("PEM block not found")
}
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
key, ok := parsed.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("private key is not RSA")
}
return key, nil
}
// AppJWT creates a GitHub App JWT with the recommended one-minute clock skew
// and a lifetime below GitHub's ten-minute maximum.
func (c *AppClient) AppJWT() (string, error) {
now := c.now().UTC()
header, err := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
if err != nil {
return "", err
}
claims, err := json.Marshal(map[string]any{
"iat": now.Add(-time.Minute).Unix(),
"exp": now.Add(9 * time.Minute).Unix(),
"iss": strconv.FormatInt(c.appID, 10),
})
if err != nil {
return "", err
}
unsigned := base64.RawURLEncoding.EncodeToString(header) + "." +
base64.RawURLEncoding.EncodeToString(claims)
digest := sha256.Sum256([]byte(unsigned))
signature, err := rsa.SignPKCS1v15(rand.Reader, c.privateKey, crypto.SHA256, digest[:])
if err != nil {
return "", fmt.Errorf("sign GitHub App JWT: %w", err)
}
return unsigned + "." + base64.RawURLEncoding.EncodeToString(signature), nil
}
// GetInstallation returns App-authenticated installation metadata.
func (c *AppClient) GetInstallation(ctx context.Context, installationID int64) (AppInstallation, error) {
var response struct {
ID int64 `json:"id"`
Account struct {
ID int64 `json:"id"`
Login string `json:"login"`
Type string `json:"type"`
} `json:"account"`
Permissions map[string]string `json:"permissions"`
SuspendedAt *time.Time `json:"suspended_at"`
}
if err := c.appRequest(ctx, http.MethodGet, fmt.Sprintf("/app/installations/%d", installationID), nil, &response); err != nil {
return AppInstallation{}, err
}
return AppInstallation{
ID: response.ID,
AccountID: response.Account.ID,
AccountLogin: response.Account.Login,
AccountType: response.Account.Type,
Permissions: permissionLevels(response.Permissions),
SuspendedAt: response.SuspendedAt,
}, nil
}
func (c *AppClient) GetAuthenticatedApp(ctx context.Context) (AuthenticatedApp, error) {
var response struct {
ID int64 `json:"id"`
ClientID string `json:"client_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Owner struct {
Login string `json:"login"`
Type string `json:"type"`
} `json:"owner"`
ExternalURL string `json:"external_url"`
Permissions map[string]string `json:"permissions"`
Events []string `json:"events"`
}
if err := c.appRequest(ctx, http.MethodGet, "/app", nil, &response); err != nil {
return AuthenticatedApp{}, err
}
return AuthenticatedApp{
ID: response.ID, ClientID: response.ClientID, Slug: response.Slug, Name: response.Name,
OwnerLogin: response.Owner.Login, OwnerType: response.Owner.Type,
ExternalURL: response.ExternalURL,
Permissions: response.Permissions, Events: response.Events,
}, nil
}
func (c *AppClient) GetWebhookConfig(ctx context.Context) (AppWebhookConfig, error) {
var response struct {
URL string `json:"url"`
ContentType string `json:"content_type"`
InsecureSSL any `json:"insecure_ssl"`
}
if err := c.appRequest(ctx, http.MethodGet, "/app/hook/config", nil, &response); err != nil {
return AppWebhookConfig{}, err
}
return AppWebhookConfig{
URL: response.URL, ContentType: response.ContentType,
InsecureSSL: fmt.Sprint(response.InsecureSSL),
}, nil
}
// MintInstallationToken requests a permission-scoped installation token.
func (c *AppClient) MintInstallationToken(
ctx context.Context,
installationID int64,
permissions InstallationPermissions,
repositories []string,
) (InstallationToken, error) {
if installationID <= 0 {
return InstallationToken{}, fmt.Errorf("installation ID must be positive")
}
if err := validateInstallationPermissions(permissions); err != nil {
return InstallationToken{}, err
}
body := struct {
Permissions InstallationPermissions `json:"permissions,omitempty"`
Repositories []string `json:"repositories,omitempty"`
}{Permissions: permissions, Repositories: repositories}
var response struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
Permissions map[string]string `json:"permissions"`
}
if err := c.appRequest(
ctx,
http.MethodPost,
fmt.Sprintf("/app/installations/%d/access_tokens", installationID),
body,
&response,
); err != nil {
return InstallationToken{}, err
}
if response.Token == "" {
return InstallationToken{}, errors.New("GitHub returned an empty installation token")
}
return InstallationToken{
Token: response.Token,
ExpiresAt: response.ExpiresAt,
Permissions: permissionLevels(response.Permissions),
Principal: TokenPrincipal{
Kind: TokenCredentialInstallation,
PrincipalID: fmt.Sprintf("installation:%d", installationID),
InstallationID: installationID,
},
}, nil
}
func (c *AppClient) appRequest(ctx context.Context, method, path string, body, out any) error {
jwt, err := c.AppJWT()
if err != nil {
return err
}
var requestBody io.Reader
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal GitHub App request: %w", err)
}
requestBody = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.baseURL, "/")+path, requestBody)
if err != nil {
return err
}
req.Header.Set("Accept", githubAccept)
req.Header.Set("Authorization", "Bearer "+jwt)
req.Header.Set("X-GitHub-Api-Version", githubAPIVersion)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("GitHub App request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, maxGitHubAppResponseSize+1))
if readErr != nil {
return fmt.Errorf("read GitHub App response: %w", readErr)
}
if len(responseBody) > maxGitHubAppResponseSize {
return ErrGitHubAppResponseTooLarge
}
if resp.StatusCode >= http.StatusBadRequest {
return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: path, Body: string(responseBody)}
}
if err := json.Unmarshal(responseBody, out); err != nil {
return fmt.Errorf("decode GitHub App response: %w", err)
}
return nil
}
func validateInstallationPermissions(permissions InstallationPermissions) error {
for name, level := range permissions {
if strings.TrimSpace(name) == "" {
return errors.New("installation permission name must not be empty")
}
if level != PermissionRead && level != PermissionWrite {
return fmt.Errorf("invalid access level for installation permission %q", name)
}
}
return nil
}
func permissionLevels(input map[string]string) InstallationPermissions {
permissions := make(InstallationPermissions, len(input))
for name, level := range input {
permissions[name] = PermissionLevel(level)
}
return permissions
}
// CapabilitiesForPermissions maps GitHub's repository permissions to Kandev
// operations. Write permission implies read permission.
func CapabilitiesForPermissions(permissions InstallationPermissions) map[GitHubAppCapability]bool {
capabilities := make(map[GitHubAppCapability]bool)
grant := func(permission string, readCapability, writeCapability GitHubAppCapability) {
level := permissions[permission]
if level == PermissionRead || level == PermissionWrite {
capabilities[readCapability] = true
}
if writeCapability != "" && level == PermissionWrite {
capabilities[writeCapability] = true
}
}
grant("metadata", CapabilityRepositoryRead, "")
grant("contents", CapabilityGitRead, CapabilityGitWrite)
grant("pull_requests", CapabilityPullRequestRead, CapabilityPullRequestWrite)
grant("issues", CapabilityIssueRead, CapabilityIssueWrite)
grant("checks", CapabilityChecksRead, "")
grant("statuses", CapabilityStatusesRead, "")
grant("actions", CapabilityActionsRead, "")
grant("administration", CapabilityBranchProtectionRead, "")
grant("members", CapabilityMembersRead, "")
if permissions["workflows"] == PermissionWrite {
capabilities[CapabilityWorkflowsWrite] = true
}
return capabilities
}