Skip to content

Commit 98bc392

Browse files
committed
feat(serde): support Git repo cloning via GitHub app
1 parent 7fea976 commit 98bc392

File tree

7 files changed

+435
-1
lines changed

7 files changed

+435
-1
lines changed

backend/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ require (
2828
github.com/go-git/go-billy/v5 v5.7.0
2929
github.com/go-git/go-git/v5 v5.16.5
3030
github.com/go-viper/mapstructure/v2 v2.4.0
31+
github.com/golang-jwt/jwt/v5 v5.3.0
3132
github.com/google/go-cmp v0.7.0
3233
github.com/google/uuid v1.6.0
3334
github.com/gorilla/schema v1.4.1
@@ -133,7 +134,6 @@ require (
133134
github.com/go-resty/resty/v2 v2.16.5 // indirect
134135
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
135136
github.com/gofrs/uuid/v5 v5.3.2 // indirect
136-
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
137137
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
138138
github.com/golang/snappy v1.0.0 // indirect
139139
github.com/google/cel-go v0.26.1 // indirect

backend/pkg/config/git.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type Git struct {
3838
// Authentication Configs
3939
BasicAuth GitAuthBasicAuth `yaml:"basicAuth"`
4040
SSH GitAuthSSH `yaml:"ssh"`
41+
GithubApp GitGithubApp `yaml:"githubApp"`
4142

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

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

66+
if err := c.GithubApp.Validate(); err != nil {
67+
return err
68+
}
69+
6470
return c.Repository.Validate()
6571
}
6672

backend/pkg/config/git_auth_ssh.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,5 @@ func (c *GitAuthSSH) RegisterFlagsWithPrefix(f *flag.FlagSet, prefix string) {
2525
f.StringVar(&c.PrivateKey, prefix+"git.ssh.private-key", "", "Private key for Git authentication")
2626
f.StringVar(&c.Passphrase, prefix+"git.ssh.passphrase", "", "Passphrase to decrypt private key")
2727
}
28+
29+
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright 2022 Redpanda Data, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the file licenses/BSL.md
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0
9+
10+
package config
11+
12+
import (
13+
"errors"
14+
"flag"
15+
)
16+
17+
// GitGithubApp is the configuration to authenticate against Git via GitHub App.
18+
type GitGithubApp struct {
19+
Enabled bool `yaml:"enabled"`
20+
AppID int64 `yaml:"appId"`
21+
InstallationID int64 `yaml:"installationId"`
22+
PrivateKey string `yaml:"privateKey"`
23+
PrivateKeyFilePath string `yaml:"privateKeyFilepath"`
24+
}
25+
26+
// RegisterFlagsWithPrefix for sensitive GitHub App configs
27+
func (c *GitGithubApp) RegisterFlagsWithPrefix(f *flag.FlagSet, prefix string) {
28+
f.StringVar(&c.PrivateKey, prefix+"git.github-app.private-key", "", "Private key for GitHub App authentication")
29+
}
30+
31+
// Validate the GitHub App authentication configuration.
32+
func (c *GitGithubApp) Validate() error {
33+
if !c.Enabled {
34+
return nil
35+
}
36+
37+
if c.AppID <= 0 {
38+
return errors.New("github app authentication is enabled but appId is not set or invalid")
39+
}
40+
41+
if c.InstallationID <= 0 {
42+
return errors.New("github app authentication is enabled but installationId is not set or invalid")
43+
}
44+
45+
if c.PrivateKey == "" && c.PrivateKeyFilePath == "" {
46+
return errors.New("github app authentication is enabled but neither privateKey nor privateKeyFilepath is set")
47+
}
48+
49+
return nil
50+
}

backend/pkg/git/github_app_auth.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright 2022 Redpanda Data, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0
9+
10+
package git
11+
12+
import (
13+
"crypto/rsa"
14+
"crypto/x509"
15+
"encoding/json"
16+
"encoding/pem"
17+
"fmt"
18+
"io"
19+
"log/slog"
20+
"net/http"
21+
"os"
22+
"sync"
23+
"time"
24+
25+
"github.com/golang-jwt/jwt/v5"
26+
27+
"github.com/redpanda-data/console/backend/pkg/config"
28+
)
29+
30+
const (
31+
gitHubAPIBaseURL = "https://api.github.com"
32+
tokenExpiryBuffer = 5 * time.Minute
33+
)
34+
35+
// gitHubAppAuth implements the go-git http.AuthMethod interface for GitHub App authentication.
36+
// It generates short-lived installation access tokens by signing JWTs with the App's private key.
37+
type gitHubAppAuth struct {
38+
appID int64
39+
installationID int64
40+
privateKey *rsa.PrivateKey
41+
logger *slog.Logger
42+
apiBaseURL string
43+
44+
mu sync.Mutex
45+
token string
46+
expiry time.Time
47+
}
48+
49+
type installationTokenResponse struct {
50+
Token string `json:"token"`
51+
ExpiresAt time.Time `json:"expires_at"`
52+
}
53+
54+
func (a *gitHubAppAuth) SetAuth(r *http.Request) {
55+
token, err := a.getValidToken()
56+
if err != nil {
57+
a.logger.Error("failed to get GitHub App installation token", slog.Any("error", err))
58+
return
59+
}
60+
61+
r.SetBasicAuth("x-access-token", token)
62+
}
63+
64+
func (a *gitHubAppAuth) Name() string {
65+
return "http-github-app-auth"
66+
}
67+
68+
func (a *gitHubAppAuth) String() string {
69+
return fmt.Sprintf("http-github-app-auth - GitHub App ID: %d", a.appID)
70+
}
71+
72+
func (a *gitHubAppAuth) getValidToken() (string, error) {
73+
a.mu.Lock()
74+
defer a.mu.Unlock()
75+
76+
if a.token != "" && time.Now().Before(a.expiry.Add(-tokenExpiryBuffer)) {
77+
return a.token, nil
78+
}
79+
80+
return a.refreshToken()
81+
}
82+
83+
func (a *gitHubAppAuth) refreshToken() (string, error) {
84+
now := time.Now()
85+
claims := jwt.RegisteredClaims{
86+
IssuedAt: jwt.NewNumericDate(now),
87+
ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)),
88+
Issuer: fmt.Sprintf("%d", a.appID),
89+
}
90+
91+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
92+
signedJWT, err := token.SignedString(a.privateKey)
93+
if err != nil {
94+
return "", fmt.Errorf("failed to sign JWT: %w", err)
95+
}
96+
97+
url := fmt.Sprintf("%s/app/installations/%d/access_tokens", a.apiBaseURL, a.installationID)
98+
req, err := http.NewRequest(http.MethodPost, url, nil)
99+
if err != nil {
100+
return "", fmt.Errorf("failed to create request: %w", err)
101+
}
102+
103+
req.Header.Set("Authorization", "Bearer "+signedJWT)
104+
req.Header.Set("Accept", "application/vnd.github+json")
105+
106+
resp, err := http.DefaultClient.Do(req)
107+
if err != nil {
108+
return "", fmt.Errorf("failed to request installation token: %w", err)
109+
}
110+
111+
defer resp.Body.Close()
112+
113+
if resp.StatusCode != http.StatusCreated {
114+
body, _ := io.ReadAll(resp.Body)
115+
return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
116+
}
117+
118+
var tokenResp installationTokenResponse
119+
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
120+
return "", fmt.Errorf("failed to decode installation token response: %w", err)
121+
}
122+
123+
a.token = tokenResp.Token
124+
a.expiry = tokenResp.ExpiresAt
125+
a.logger.Debug("refreshed GitHub App installation token",
126+
slog.Time("expires_at", tokenResp.ExpiresAt))
127+
128+
return a.token, nil
129+
}
130+
131+
func parseRSAPrivateKey(pemData []byte) (*rsa.PrivateKey, error) {
132+
block, _ := pem.Decode(pemData)
133+
if block == nil {
134+
return nil, fmt.Errorf("failed to decode PEM block")
135+
}
136+
137+
// Try PKCS#1 first (RSA PRIVATE KEY)
138+
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
139+
return key, nil
140+
}
141+
142+
// Try PKCS#8 (PRIVATE KEY)
143+
keyInterface, err := x509.ParsePKCS8PrivateKey(block.Bytes)
144+
if err != nil {
145+
return nil, fmt.Errorf("failed to parse private key (tried PKCS#1 and PKCS#8): %w", err)
146+
}
147+
148+
key, ok := keyInterface.(*rsa.PrivateKey)
149+
if !ok {
150+
return nil, fmt.Errorf("PKCS#8 key is not an RSA private key")
151+
}
152+
153+
return key, nil
154+
}
155+
156+
func loadPrivateKeyPEM(cfg config.GitGithubApp) ([]byte, error) {
157+
if cfg.PrivateKey != "" {
158+
return []byte(cfg.PrivateKey), nil
159+
}
160+
161+
data, err := os.ReadFile(cfg.PrivateKeyFilePath)
162+
if err != nil {
163+
return nil, fmt.Errorf("failed to read private key file %q: %w", cfg.PrivateKeyFilePath, err)
164+
}
165+
166+
return data, nil
167+
}
168+
169+
func buildGithubAppAuth(cfg config.GitGithubApp, logger *slog.Logger) (*gitHubAppAuth, error) {
170+
pemData, err := loadPrivateKeyPEM(cfg)
171+
if err != nil {
172+
return nil, err
173+
}
174+
175+
privateKey, err := parseRSAPrivateKey(pemData)
176+
if err != nil {
177+
return nil, fmt.Errorf("failed to parse GitHub App private key: %w", err)
178+
}
179+
180+
return &gitHubAppAuth{
181+
appID: cfg.AppID,
182+
installationID: cfg.InstallationID,
183+
privateKey: privateKey,
184+
logger: logger,
185+
apiBaseURL: gitHubAPIBaseURL,
186+
}, nil
187+
}

0 commit comments

Comments
 (0)