Skip to content

Commit 6daa5c0

Browse files
committed
feat: Add GitHub App authentication for git cloning and releases
Implements GitHub App authentication to enable authenticated access to private repositories and releases. This provides better security and higher rate limits compared to personal access tokens. Key features: - JWT generation with RSA signing for GitHub App authentication - Installation token management with 1-hour expiration and auto-refresh - Token caching to minimize GitHub API calls - Credential injection into git clone/fetch operations - Support for both git strategy and github-releases strategy - Falls back to system credentials if GitHub App not configured Implementation details: - New internal/githubapp package for authentication logic - Updated gitclone package to support credential providers - Modified git commands to inject tokens into GitHub URLs - Added GitHub App configuration blocks to both strategies - Updated configuration examples in cachew.hcl Usage: Configure GitHub App credentials (app ID, private key path, and installation IDs per org) in the git and github-releases strategy blocks to enable authenticated access.
1 parent f1440b8 commit 6daa5c0

15 files changed

Lines changed: 525 additions & 60 deletions

File tree

cachew.hcl

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
# strategy git {}
2-
# strategy docker {}
3-
# strategy hermit {}
4-
51
# Artifactory caching proxy strategy
62
# artifactory "example.jfrog.io" {
73
# target = "https://example.jfrog.io"
@@ -12,6 +8,12 @@ log {
128
level = "debug"
139
}
1410

11+
github-app {
12+
app-id = "${GITHUB_APP_ID}"
13+
private-key-path = "${GITHUB_APP_PRIVATE_KEY_PATH}"
14+
installations-json = "${GITHUB_APP_INSTALLATIONS}"
15+
}
16+
1517
git {
1618
mirror-root = "./state/git-mirrors"
1719
bundle-interval = "24h"

cmd/cachewd/main.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919

2020
"github.com/block/cachew/internal/cache"
2121
"github.com/block/cachew/internal/config"
22+
"github.com/block/cachew/internal/githubapp"
2223
"github.com/block/cachew/internal/httputil"
2324
"github.com/block/cachew/internal/jobscheduler"
2425
"github.com/block/cachew/internal/logging"
@@ -34,6 +35,7 @@ type GlobalConfig struct {
3435
SchedulerConfig jobscheduler.Config `embed:"" hcl:"scheduler,block" prefix:"scheduler-"`
3536
LoggingConfig logging.Config `embed:"" hcl:"log,block" prefix:"log-"`
3637
MetricsConfig metrics.Config `embed:"" hcl:"metrics,block" prefix:"metrics-"`
38+
GithubAppConfig *githubapp.Config `embed:"" hcl:"github-app,block,optional" prefix:"github-app-"`
3739
}
3840

3941
var cli struct {
@@ -62,6 +64,8 @@ func main() {
6264

6365
scheduler := jobscheduler.New(ctx, cli.SchedulerConfig)
6466

67+
githubapp.SetShared(initializeGitHubApp(ctx, logger, cli.GithubAppConfig))
68+
6569
cr, sr := newRegistries(scheduler)
6670

6771
// Commands
@@ -127,6 +131,36 @@ func printSchema(kctx *kong.Context, cr *cache.Registry, sr *strategy.Registry)
127131
}
128132
}
129133

134+
func initializeGitHubApp(ctx context.Context, logger *slog.Logger, cfg *githubapp.Config) *githubapp.TokenManager {
135+
if cfg == nil {
136+
return nil
137+
}
138+
139+
if err := cfg.Initialize(logger); err != nil {
140+
logger.WarnContext(ctx, "Failed to initialize GitHub App config",
141+
slog.String("error", err.Error()))
142+
return nil
143+
}
144+
145+
if !cfg.IsConfigured() {
146+
return nil
147+
}
148+
149+
tm, err := githubapp.NewTokenManager(*cfg, githubapp.DefaultTokenCacheConfig())
150+
if err != nil {
151+
logger.ErrorContext(ctx, "Failed to create GitHub App token manager",
152+
slog.String("error", err.Error()))
153+
return nil
154+
}
155+
156+
logger.InfoContext(ctx, "Global GitHub App authentication enabled",
157+
slog.String("app_id", cfg.AppID),
158+
slog.String("private_key_path", cfg.PrivateKeyPath),
159+
slog.Int("installations", len(cfg.Installations)))
160+
161+
return tm
162+
}
163+
130164
func newMux(ctx context.Context, cr *cache.Registry, sr *strategy.Registry, providersConfig *hcl.AST) (*http.ServeMux, error) {
131165
mux := http.NewServeMux()
132166

docker/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ ARG TARGETARCH
66
SHELL ["/bin/sh", "-o", "pipefail", "-c"]
77

88
# Install runtime dependencies for git operations and TLS
9-
RUN apk add --no-cache ca-certificates git tzdata && \
9+
RUN apk add --no-cache ca-certificates git git-daemon tzdata && \
1010
addgroup -g 1000 cachew && \
1111
adduser -D -u 1000 -G cachew cachew
1212

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.25.5
55
require (
66
github.com/alecthomas/hcl/v2 v2.5.0
77
github.com/alecthomas/kong v1.13.0
8+
github.com/golang-jwt/jwt/v5 v5.3.1
89
github.com/goproxy/goproxy v0.25.0
910
github.com/lmittmann/tint v1.1.2
1011
github.com/minio/minio-go/v7 v7.0.97

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
3535
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
3636
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
3737
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
38+
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
39+
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
3840
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
3941
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
4042
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=

internal/config/config.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func Load(
9393
vars map[string]string,
9494
) error {
9595
logger := logging.FromContext(ctx)
96-
expandVars(ast, vars)
96+
ExpandVars(ast, vars)
9797

9898
strategyCandidates := []*hcl.Block{
9999
// Always enable the default API strategy
@@ -138,7 +138,7 @@ func Load(
138138
return nil
139139
}
140140

141-
func expandVars(ast *hcl.AST, vars map[string]string) {
141+
func ExpandVars(ast *hcl.AST, vars map[string]string) {
142142
_ = hcl.Visit(ast, func(node hcl.Node, next func() error) error { //nolint:errcheck
143143
attr, ok := node.(*hcl.Attribute)
144144
if ok {

internal/gitclone/command.go

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,23 @@ package gitclone
55
import (
66
"bufio"
77
"context"
8+
"fmt"
89
"os/exec"
910
"strings"
1011

1112
"github.com/alecthomas/errors"
1213
)
1314

14-
func gitCommand(ctx context.Context, url string, args ...string) (*exec.Cmd, error) {
15+
func gitCommand(ctx context.Context, url string, credentialProvider CredentialProvider, args ...string) (*exec.Cmd, error) {
16+
modifiedURL := url
17+
if credentialProvider != nil && strings.Contains(url, "github.com") {
18+
token, err := credentialProvider.GetTokenForURL(ctx, url)
19+
if err == nil && token != "" {
20+
modifiedURL = injectTokenIntoURL(url, token)
21+
}
22+
// If error getting token, fall back to original URL (system credentials)
23+
}
24+
1525
configArgs, err := getInsteadOfDisableArgsForURL(ctx, url)
1626
if err != nil {
1727
return nil, errors.Wrap(err, "get insteadOf disable args")
@@ -23,8 +33,34 @@ func gitCommand(ctx context.Context, url string, args ...string) (*exec.Cmd, err
2333
}
2434
allArgs = append(allArgs, args...)
2535

26-
cmd := exec.CommandContext(ctx, "git", allArgs...)
27-
return cmd, nil
36+
// Replace URL in args if it was modified for authentication
37+
if modifiedURL != url {
38+
for i, arg := range allArgs {
39+
if arg == url {
40+
allArgs[i] = modifiedURL
41+
}
42+
}
43+
}
44+
45+
return exec.CommandContext(ctx, "git", allArgs...), nil
46+
}
47+
48+
// Converts https://github.com/org/repo to https://x-access-token:TOKEN@github.com/org/repo
49+
func injectTokenIntoURL(url, token string) string {
50+
if token == "" {
51+
return url
52+
}
53+
54+
if strings.HasPrefix(url, "https://github.com/") {
55+
return strings.Replace(url, "https://github.com/", fmt.Sprintf("https://x-access-token:%s@github.com/", token), 1)
56+
}
57+
58+
// Upgrade http to https when adding token for security
59+
if strings.HasPrefix(url, "http://github.com/") {
60+
return strings.Replace(url, "http://github.com/", fmt.Sprintf("https://x-access-token:%s@github.com/", token), 1)
61+
}
62+
63+
return url
2864
}
2965

3066
func getInsteadOfDisableArgsForURL(ctx context.Context, targetURL string) ([]string, error) {

internal/gitclone/command_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func TestGetInsteadOfDisableArgsForURL(t *testing.T) {
4545
func TestGitCommand(t *testing.T) {
4646
ctx := context.Background()
4747

48-
cmd, err := gitCommand(ctx, "https://github.com/user/repo", "version")
48+
cmd, err := gitCommand(ctx, "https://github.com/user/repo", nil, "version")
4949
assert.NoError(t, err)
5050

5151
assert.NotZero(t, cmd)
@@ -59,7 +59,7 @@ func TestGitCommand(t *testing.T) {
5959
func TestGitCommandWithEmptyURL(t *testing.T) {
6060
ctx := context.Background()
6161

62-
cmd, err := gitCommand(ctx, "", "version")
62+
cmd, err := gitCommand(ctx, "", nil, "version")
6363
assert.NoError(t, err)
6464

6565
assert.NotZero(t, cmd)

internal/gitclone/manager.go

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -74,24 +74,31 @@ type Config struct {
7474
GitConfig GitTuningConfig
7575
}
7676

77+
// CredentialProvider provides credentials for git operations.
78+
type CredentialProvider interface {
79+
GetTokenForURL(ctx context.Context, url string) (string, error)
80+
}
81+
7782
type Repository struct {
78-
mu sync.RWMutex
79-
state State
80-
path string
81-
upstreamURL string
82-
lastFetch time.Time
83-
lastRefCheck time.Time
84-
refCheckValid bool
85-
fetchSem chan struct{}
83+
mu sync.RWMutex
84+
state State
85+
path string
86+
upstreamURL string
87+
lastFetch time.Time
88+
lastRefCheck time.Time
89+
refCheckValid bool
90+
fetchSem chan struct{}
91+
credentialProvider CredentialProvider
8692
}
8793

8894
type Manager struct {
89-
config Config
90-
clones map[string]*Repository
91-
clonesMu sync.RWMutex
95+
config Config
96+
clones map[string]*Repository
97+
clonesMu sync.RWMutex
98+
credentialProvider CredentialProvider
9299
}
93100

94-
func NewManager(_ context.Context, config Config) (*Manager, error) {
101+
func NewManager(_ context.Context, config Config, credentialProvider CredentialProvider) (*Manager, error) {
95102
if config.RootDir == "" {
96103
return nil, errors.New("RootDir is required")
97104
}
@@ -101,8 +108,9 @@ func NewManager(_ context.Context, config Config) (*Manager, error) {
101108
}
102109

103110
return &Manager{
104-
config: config,
105-
clones: make(map[string]*Repository),
111+
config: config,
112+
clones: make(map[string]*Repository),
113+
credentialProvider: credentialProvider,
106114
}, nil
107115
}
108116

@@ -125,10 +133,11 @@ func (m *Manager) GetOrCreate(_ context.Context, upstreamURL string) (*Repositor
125133
clonePath := m.clonePathForURL(upstreamURL)
126134

127135
repo = &Repository{
128-
state: StateEmpty,
129-
path: clonePath,
130-
upstreamURL: upstreamURL,
131-
fetchSem: make(chan struct{}, 1),
136+
state: StateEmpty,
137+
path: clonePath,
138+
upstreamURL: upstreamURL,
139+
fetchSem: make(chan struct{}, 1),
140+
credentialProvider: m.credentialProvider,
132141
}
133142

134143
gitDir := filepath.Join(clonePath, ".git")
@@ -152,6 +161,10 @@ func (m *Manager) Config() Config {
152161
return m.config
153162
}
154163

164+
func (m *Manager) CredentialProvider() CredentialProvider {
165+
return m.credentialProvider
166+
}
167+
155168
func (m *Manager) DiscoverExisting(_ context.Context) ([]*Repository, error) {
156169
var discovered []*Repository
157170
err := filepath.Walk(m.config.RootDir, func(path string, info os.FileInfo, err error) error {
@@ -195,10 +208,11 @@ func (m *Manager) DiscoverExisting(_ context.Context) ([]*Repository, error) {
195208
upstreamURL := "https://" + host + "/" + repoPath
196209

197210
repo := &Repository{
198-
state: StateReady,
199-
path: path,
200-
upstreamURL: upstreamURL,
201-
fetchSem: make(chan struct{}, 1),
211+
state: StateReady,
212+
path: path,
213+
upstreamURL: upstreamURL,
214+
fetchSem: make(chan struct{}, 1),
215+
credentialProvider: m.credentialProvider,
202216
}
203217
repo.fetchSem <- struct{}{}
204218

@@ -304,7 +318,7 @@ func (r *Repository) executeClone(ctx context.Context, config Config) error {
304318
r.upstreamURL, r.path,
305319
}
306320

307-
cmd, err := gitCommand(ctx, r.upstreamURL, args...)
321+
cmd, err := gitCommand(ctx, r.upstreamURL, r.credentialProvider, args...)
308322
if err != nil {
309323
return errors.Wrap(err, "create git command")
310324
}
@@ -320,7 +334,7 @@ func (r *Repository) executeClone(ctx context.Context, config Config) error {
320334
return errors.Wrapf(err, "configure fetch refspec: %s", string(output))
321335
}
322336

323-
cmd, err = gitCommand(ctx, r.upstreamURL, "-C", r.path,
337+
cmd, err = gitCommand(ctx, r.upstreamURL, r.credentialProvider, "-C", r.path,
324338
"-c", "http.postBuffer="+strconv.Itoa(config.GitConfig.PostBuffer),
325339
"-c", "http.lowSpeedLimit="+strconv.Itoa(config.GitConfig.LowSpeedLimit),
326340
"-c", "http.lowSpeedTime="+strconv.Itoa(int(config.GitConfig.LowSpeedTime.Seconds())),
@@ -357,7 +371,7 @@ func (r *Repository) Fetch(ctx context.Context, config Config) error {
357371
r.mu.Lock()
358372

359373
// #nosec G204 - r.path is controlled by us
360-
cmd, err := gitCommand(ctx, r.upstreamURL, "-C", r.path,
374+
cmd, err := gitCommand(ctx, r.upstreamURL, r.credentialProvider, "-C", r.path,
361375
"-c", "http.postBuffer="+strconv.Itoa(config.GitConfig.PostBuffer),
362376
"-c", "http.lowSpeedLimit="+strconv.Itoa(config.GitConfig.LowSpeedLimit),
363377
"-c", "http.lowSpeedTime="+strconv.Itoa(int(config.GitConfig.LowSpeedTime.Seconds())),
@@ -447,7 +461,7 @@ func (r *Repository) GetLocalRefs(ctx context.Context) (map[string]string, error
447461

448462
func (r *Repository) GetUpstreamRefs(ctx context.Context) (map[string]string, error) {
449463
// #nosec G204 - r.upstreamURL is controlled by us
450-
cmd, err := gitCommand(ctx, r.upstreamURL, "ls-remote", r.upstreamURL)
464+
cmd, err := gitCommand(ctx, r.upstreamURL, r.credentialProvider, "ls-remote", r.upstreamURL)
451465
if err != nil {
452466
return nil, errors.Wrap(err, "create git command")
453467
}

internal/gitclone/manager_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func TestNewManager(t *testing.T) {
2121
GitConfig: DefaultGitTuningConfig(),
2222
}
2323

24-
manager, err := NewManager(context.Background(), config)
24+
manager, err := NewManager(context.Background(), config, nil)
2525
assert.NoError(t, err)
2626
assert.NotZero(t, manager)
2727
assert.Equal(t, tmpDir, manager.config.RootDir)
@@ -33,7 +33,7 @@ func TestNewManager_RequiresRootDir(t *testing.T) {
3333
RefCheckInterval: 10 * time.Second,
3434
}
3535

36-
_, err := NewManager(context.Background(), config)
36+
_, err := NewManager(context.Background(), config, nil)
3737
assert.Error(t, err)
3838
assert.Contains(t, err.Error(), "RootDir is required")
3939
}
@@ -47,7 +47,7 @@ func TestManager_GetOrCreate(t *testing.T) {
4747
GitConfig: DefaultGitTuningConfig(),
4848
}
4949

50-
manager, err := NewManager(context.Background(), config)
50+
manager, err := NewManager(context.Background(), config, nil)
5151
assert.NoError(t, err)
5252

5353
upstreamURL := "https://github.com/user/repo"
@@ -73,7 +73,7 @@ func TestManager_GetOrCreate_ExistingClone(t *testing.T) {
7373
GitConfig: DefaultGitTuningConfig(),
7474
}
7575

76-
manager, err := NewManager(context.Background(), config)
76+
manager, err := NewManager(context.Background(), config, nil)
7777
assert.NoError(t, err)
7878

7979
repoPath := filepath.Join(tmpDir, "github.com", "user", "repo")
@@ -98,7 +98,7 @@ func TestManager_Get(t *testing.T) {
9898
GitConfig: DefaultGitTuningConfig(),
9999
}
100100

101-
manager, err := NewManager(context.Background(), config)
101+
manager, err := NewManager(context.Background(), config, nil)
102102
assert.NoError(t, err)
103103

104104
upstreamURL := "https://github.com/user/repo"
@@ -123,7 +123,7 @@ func TestManager_DiscoverExisting(t *testing.T) {
123123
GitConfig: DefaultGitTuningConfig(),
124124
}
125125

126-
manager, err := NewManager(context.Background(), config)
126+
manager, err := NewManager(context.Background(), config, nil)
127127
assert.NoError(t, err)
128128

129129
repos := []string{

0 commit comments

Comments
 (0)