Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ require (
github.com/go-git/go-billy/v5 v5.7.0
github.com/go-git/go-git/v5 v5.16.5
github.com/go-viper/mapstructure/v2 v2.4.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/gorilla/schema v1.4.1
Expand Down Expand Up @@ -133,7 +134,6 @@ require (
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/gofrs/uuid/v5 v5.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/cel-go v0.26.1 // indirect
Expand Down
6 changes: 6 additions & 0 deletions backend/pkg/config/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Git struct {
// Authentication Configs
BasicAuth GitAuthBasicAuth `yaml:"basicAuth"`
SSH GitAuthSSH `yaml:"ssh"`
GithubApp GitGithubApp `yaml:"githubApp"`

// CloneSubmodules enables shallow cloning of submodules at recursion depth of 1.
CloneSubmodules bool `yaml:"cloneSubmodules"`
Expand All @@ -47,6 +48,7 @@ type Git struct {
func (c *Git) RegisterFlagsWithPrefix(f *flag.FlagSet, prefix string) {
c.BasicAuth.RegisterFlagsWithPrefix(f, prefix)
c.SSH.RegisterFlagsWithPrefix(f, prefix)
c.GithubApp.RegisterFlagsWithPrefix(f, prefix)
}

// Validate all root and child config structs
Expand All @@ -61,6 +63,10 @@ func (c *Git) Validate() error {
return errors.New("git config is enabled but file max size is <= 0")
}

if err := c.GithubApp.Validate(); err != nil {
return err
}

return c.Repository.Validate()
}

Expand Down
2 changes: 2 additions & 0 deletions backend/pkg/config/git_auth_ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ func (c *GitAuthSSH) RegisterFlagsWithPrefix(f *flag.FlagSet, prefix string) {
f.StringVar(&c.PrivateKey, prefix+"git.ssh.private-key", "", "Private key for Git authentication")
f.StringVar(&c.Passphrase, prefix+"git.ssh.passphrase", "", "Passphrase to decrypt private key")
}


50 changes: 50 additions & 0 deletions backend/pkg/config/github_auth_app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright 2022 Redpanda Data, Inc.
//
// Use of this software is governed by the Business Source License
// included in the file licenses/BSL.md
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0

package config

import (
"errors"
"flag"
)

// GitGithubApp is the configuration to authenticate against Git via GitHub App.
type GitGithubApp struct {
Enabled bool `yaml:"enabled"`
AppID int64 `yaml:"appId"`
InstallationID int64 `yaml:"installationId"`
PrivateKey string `yaml:"privateKey"`
PrivateKeyFilePath string `yaml:"privateKeyFilepath"`
}

// RegisterFlagsWithPrefix for sensitive GitHub App configs
func (c *GitGithubApp) RegisterFlagsWithPrefix(f *flag.FlagSet, prefix string) {
f.StringVar(&c.PrivateKey, prefix+"git.github-app.private-key", "", "Private key for GitHub App authentication")
}

// Validate the GitHub App authentication configuration.
func (c *GitGithubApp) Validate() error {
if !c.Enabled {
return nil
}

if c.AppID <= 0 {
return errors.New("github app authentication is enabled but appId is not set or invalid")
}

if c.InstallationID <= 0 {
return errors.New("github app authentication is enabled but installationId is not set or invalid")
}

if c.PrivateKey == "" && c.PrivateKeyFilePath == "" {
return errors.New("github app authentication is enabled but neither privateKey nor privateKeyFilepath is set")
}

return nil
}
187 changes: 187 additions & 0 deletions backend/pkg/git/github_app_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// Copyright 2022 Redpanda Data, Inc.
//
// Use of this software is governed by the Business Source License
// included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0

package git

import (
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"sync"
"time"

"github.com/golang-jwt/jwt/v5"

"github.com/redpanda-data/console/backend/pkg/config"
)

const (
gitHubAPIBaseURL = "https://api.github.com"
tokenExpiryBuffer = 5 * time.Minute
)

// gitHubAppAuth implements the go-git http.AuthMethod interface for GitHub App authentication.
// It generates short-lived installation access tokens by signing JWTs with the App's private key.
type gitHubAppAuth struct {
appID int64
installationID int64
privateKey *rsa.PrivateKey
logger *slog.Logger
apiBaseURL string

mu sync.Mutex
token string
expiry time.Time
}

type installationTokenResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}

func (a *gitHubAppAuth) SetAuth(r *http.Request) {
token, err := a.getValidToken()
if err != nil {
a.logger.Error("failed to get GitHub App installation token", slog.Any("error", err))
return
}

r.SetBasicAuth("x-access-token", token)
}

func (a *gitHubAppAuth) Name() string {
return "http-github-app-auth"
}

func (a *gitHubAppAuth) String() string {
return fmt.Sprintf("http-github-app-auth - GitHub App ID: %d", a.appID)
}

func (a *gitHubAppAuth) getValidToken() (string, error) {
a.mu.Lock()
defer a.mu.Unlock()

if a.token != "" && time.Now().Before(a.expiry.Add(-tokenExpiryBuffer)) {
return a.token, nil
}

return a.refreshToken()
}

func (a *gitHubAppAuth) refreshToken() (string, error) {
now := time.Now()
claims := jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)),
Issuer: fmt.Sprintf("%d", a.appID),
}

token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signedJWT, err := token.SignedString(a.privateKey)
if err != nil {
return "", fmt.Errorf("failed to sign JWT: %w", err)
}

url := fmt.Sprintf("%s/app/installations/%d/access_tokens", a.apiBaseURL, a.installationID)
req, err := http.NewRequest(http.MethodPost, url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("Authorization", "Bearer "+signedJWT)
req.Header.Set("Accept", "application/vnd.github+json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to request installation token: %w", err)
}

defer resp.Body.Close()

if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
}

var tokenResp installationTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("failed to decode installation token response: %w", err)
}

a.token = tokenResp.Token
a.expiry = tokenResp.ExpiresAt
a.logger.Debug("refreshed GitHub App installation token",
slog.Time("expires_at", tokenResp.ExpiresAt))

return a.token, nil
}

func parseRSAPrivateKey(pemData []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(pemData)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}

// Try PKCS#1 first (RSA PRIVATE KEY)
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}

// Try PKCS#8 (PRIVATE KEY)
keyInterface, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key (tried PKCS#1 and PKCS#8): %w", err)
}

key, ok := keyInterface.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("PKCS#8 key is not an RSA private key")
}

return key, nil
}

func loadPrivateKeyPEM(cfg config.GitGithubApp) ([]byte, error) {
if cfg.PrivateKey != "" {
return []byte(cfg.PrivateKey), nil
}

data, err := os.ReadFile(cfg.PrivateKeyFilePath)
if err != nil {
return nil, fmt.Errorf("failed to read private key file %q: %w", cfg.PrivateKeyFilePath, err)
}

return data, nil
}

func buildGithubAppAuth(cfg config.GitGithubApp, logger *slog.Logger) (*gitHubAppAuth, error) {
pemData, err := loadPrivateKeyPEM(cfg)
if err != nil {
return nil, err
}

privateKey, err := parseRSAPrivateKey(pemData)
if err != nil {
return nil, fmt.Errorf("failed to parse GitHub App private key: %w", err)
}

return &gitHubAppAuth{
appID: cfg.AppID,
installationID: cfg.InstallationID,
privateKey: privateKey,
logger: logger,
apiBaseURL: gitHubAPIBaseURL,
}, nil
}
Loading
Loading