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
11 changes: 5 additions & 6 deletions cachew.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,11 @@ log {

git-clone {}

github-app {
# Uncomment and add:
# app-id = "app-id-value" (Can also be passed via setting envar CACHEW_GITHUB_APP_APP_ID)
# private-key-path = "private-key-path-value" (Can also be passed via envar CACHEW_GITHUB_APP_PRIVATE_KEY_PATH)
# installations = { "myorg" : "installation-id" }
}
# github-app "my-app" {
# app-id = "app-id-value"
# private-key-path = "private-key-path-value"
# installations = { "myorg" : "installation-id" }
# }

metrics {}

Expand Down
18 changes: 9 additions & 9 deletions cmd/cachewd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@ import (
)

type GlobalConfig struct {
State string `hcl:"state" default:"./state" help:"Base directory for all state (git mirrors, cache, etc.)."`
Bind string `hcl:"bind" default:"127.0.0.1:8080" help:"Bind address for the server."`
URL string `hcl:"url" default:"http://127.0.0.1:8080/" help:"Base URL for cachewd."`
SchedulerConfig jobscheduler.Config `hcl:"scheduler,block"`
LoggingConfig logging.Config `hcl:"log,block"`
MetricsConfig metrics.Config `hcl:"metrics,block"`
GitCloneConfig gitclone.Config `hcl:"git-clone,block"`
GithubAppConfig githubapp.Config `embed:"" hcl:"github-app,block,optional" prefix:"github-app-"`
State string `hcl:"state" default:"./state" help:"Base directory for all state (git mirrors, cache, etc.)."`
Bind string `hcl:"bind" default:"127.0.0.1:8080" help:"Bind address for the server."`
URL string `hcl:"url" default:"http://127.0.0.1:8080/" help:"Base URL for cachewd."`
SchedulerConfig jobscheduler.Config `hcl:"scheduler,block"`
LoggingConfig logging.Config `hcl:"log,block"`
MetricsConfig metrics.Config `hcl:"metrics,block"`
GitCloneConfig gitclone.Config `hcl:"git-clone,block"`
GithubAppConfigs []githubapp.Config `hcl:"github-app,block,optional"`
}

type CLI struct {
Expand Down Expand Up @@ -68,7 +68,7 @@ func main() {
reaper.Start(ctx)

// Start initialising
tokenManagerProvider := githubapp.NewTokenManagerProvider(globalConfig.GithubAppConfig, logger)
tokenManagerProvider := githubapp.NewTokenManagerProvider(globalConfig.GithubAppConfigs, logger)
managerProvider := gitclone.NewManagerProvider(ctx, globalConfig.GitCloneConfig, func() (gitclone.CredentialProvider, error) {
return tokenManagerProvider()
})
Expand Down
61 changes: 5 additions & 56 deletions internal/githubapp/config.go
Original file line number Diff line number Diff line change
@@ -1,74 +1,23 @@
// Package githubapp provides GitHub App authentication and token management.
package githubapp

import (
"log/slog"
"time"

"github.com/alecthomas/errors"
)
import "time"

// Config represents the configuration for a single GitHub App.
type Config struct {
Name string `hcl:"name,label" help:"Name for this GitHub App configuration."`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we actually need a name? If we don't need this the config will be completely backwards compatible.

I don't really mind either way.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't in retrospect, initially I thought it would be useful for helping identify the app, but the org is in the map... so it's probably unnecessary.

AppID string `hcl:"app-id,optional" help:"GitHub App ID"`
PrivateKeyPath string `hcl:"private-key-path,optional" help:"Path to GitHub App private key (PEM format)"`
Installations map[string]string `hcl:"installations,optional" help:"Mapping of org names to installation IDs"`
}

// Installations maps organization names to GitHub App installation IDs.
type Installations struct {
appID string
privateKeyPath string
orgs map[string]string
}

// NewInstallations creates an Installations instance from config.
func NewInstallations(config Config, logger *slog.Logger) (*Installations, error) {
if len(config.Installations) == 0 {
return nil, errors.New("installations is required")
}

logger.Info("GitHub App config initialized",
"app_id", config.AppID,
"private_key_path", config.PrivateKeyPath,
"installations", len(config.Installations))

return &Installations{
appID: config.AppID,
privateKeyPath: config.PrivateKeyPath,
orgs: config.Installations,
}, nil
}

func (i *Installations) IsConfigured() bool {
return i != nil && i.appID != "" && i.privateKeyPath != "" && len(i.orgs) > 0
}

func (i *Installations) GetInstallationID(org string) string {
if i == nil || i.orgs == nil {
return ""
}
return i.orgs[org]
}

func (i *Installations) AppID() string {
if i == nil {
return ""
}
return i.appID
}

func (i *Installations) PrivateKeyPath() string {
if i == nil {
return ""
}
return i.privateKeyPath
}

// TokenCacheConfig configures token caching behavior.
type TokenCacheConfig struct {
RefreshBuffer time.Duration // How early to refresh before expiration
JWTExpiration time.Duration // GitHub allows max 10 minutes
}

// DefaultTokenCacheConfig returns default token cache configuration.
func DefaultTokenCacheConfig() TokenCacheConfig {
return TokenCacheConfig{
RefreshBuffer: 5 * time.Minute,
Expand Down
147 changes: 91 additions & 56 deletions internal/githubapp/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,21 @@ import (
// TokenManagerProvider is a function that lazily creates a singleton TokenManager.
type TokenManagerProvider func() (*TokenManager, error)

// NewTokenManagerProvider creates a provider that lazily initializes a TokenManager.
func NewTokenManagerProvider(config Config, logger *slog.Logger) TokenManagerProvider {
// NewTokenManagerProvider creates a provider that lazily initializes a TokenManager
// from one or more GitHub App configurations.
func NewTokenManagerProvider(configs []Config, logger *slog.Logger) TokenManagerProvider {
return sync.OnceValues(func() (*TokenManager, error) {
if config.AppID == "" || config.PrivateKeyPath == "" || len(config.Installations) == 0 {
return nil, nil // Not configured, return nil without error
}

installations, err := NewInstallations(config, logger)
if err != nil {
return nil, errors.Wrap(err, "create installations")
}

return NewTokenManager(installations, DefaultTokenCacheConfig())
return newTokenManager(configs, logger)
})
}

type TokenManager struct {
installations *Installations
cacheConfig TokenCacheConfig
jwtGenerator *JWTGenerator
httpClient *http.Client
// appState holds token management state for a single GitHub App.
type appState struct {
name string
jwtGenerator *JWTGenerator
cacheConfig TokenCacheConfig
httpClient *http.Client
orgs map[string]string // org -> installation ID

mu sync.RWMutex
tokens map[string]*cachedToken
Expand All @@ -49,80 +43,121 @@ type cachedToken struct {
expiresAt time.Time
}

func NewTokenManager(installations *Installations, cacheConfig TokenCacheConfig) (*TokenManager, error) {
if !installations.IsConfigured() {
return nil, errors.New("GitHub App not configured")
// TokenManager manages GitHub App installation tokens across one or more apps.
type TokenManager struct {
orgToApp map[string]*appState
}

func newTokenManager(configs []Config, logger *slog.Logger) (*TokenManager, error) {
orgToApp := map[string]*appState{}

for _, config := range configs {
if config.AppID == "" || config.PrivateKeyPath == "" || len(config.Installations) == 0 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be an error?

continue
}

cacheConfig := DefaultTokenCacheConfig()
jwtGen, err := NewJWTGenerator(config.AppID, config.PrivateKeyPath, cacheConfig.JWTExpiration)
if err != nil {
return nil, errors.Wrapf(err, "github app %q", config.Name)
}

app := &appState{
name: config.Name,
jwtGenerator: jwtGen,
cacheConfig: cacheConfig,
httpClient: http.DefaultClient,
orgs: config.Installations,
tokens: make(map[string]*cachedToken),
}

for org := range config.Installations {
if existing, exists := orgToApp[org]; exists {
return nil, errors.Errorf("org %q is configured in both github-app %q and %q", org, existing.name, config.Name)
}
orgToApp[org] = app
}

logger.Info("GitHub App configured",
"name", config.Name,
"app_id", config.AppID,
"orgs", len(config.Installations))
}

jwtGenerator, err := NewJWTGenerator(installations.AppID(), installations.PrivateKeyPath(), cacheConfig.JWTExpiration)
if err != nil {
return nil, errors.Wrap(err, "create JWT generator")
if len(orgToApp) == 0 {
return nil, nil //nolint:nilnil
}

return &TokenManager{
installations: installations,
cacheConfig: cacheConfig,
jwtGenerator: jwtGenerator,
httpClient: http.DefaultClient,
tokens: make(map[string]*cachedToken),
}, nil
return &TokenManager{orgToApp: orgToApp}, nil
}

// GetTokenForOrg returns an installation token for the given GitHub organization.
func (tm *TokenManager) GetTokenForOrg(ctx context.Context, org string) (string, error) {
if tm == nil {
return "", errors.New("token manager not initialized")
}
logger := logging.FromContext(ctx).With(slog.String("org", org))

installationID := tm.installations.GetInstallationID(org)
app, ok := tm.orgToApp[org]
if !ok {
return "", errors.Errorf("no GitHub App configured for org: %s", org)
}

return app.getToken(ctx, org)
}

// GetTokenForURL extracts the org from a GitHub URL and returns an installation token.
func (tm *TokenManager) GetTokenForURL(ctx context.Context, url string) (string, error) {
if tm == nil {
return "", errors.New("token manager not initialized")
}
org, err := extractOrgFromURL(url)
if err != nil {
return "", err
}

return tm.GetTokenForOrg(ctx, org)
}

func (a *appState) getToken(ctx context.Context, org string) (string, error) {
logger := logging.FromContext(ctx).With(slog.String("org", org), slog.String("app", a.name))

installationID := a.orgs[org]
if installationID == "" {
return "", errors.Errorf("no GitHub App installation configured for org: %s", org)
return "", errors.Errorf("no installation ID for org: %s", org)
}

tm.mu.RLock()
cached, exists := tm.tokens[org]
tm.mu.RUnlock()
a.mu.RLock()
cached, exists := a.tokens[org]
a.mu.RUnlock()

if exists && time.Now().Add(tm.cacheConfig.RefreshBuffer).Before(cached.expiresAt) {
if exists && time.Now().Add(a.cacheConfig.RefreshBuffer).Before(cached.expiresAt) {
logger.DebugContext(ctx, "Using cached GitHub App token")
return cached.token, nil
}

logger.DebugContext(ctx, "Fetching new GitHub App installation token",
slog.String("installation_id", installationID))

token, expiresAt, err := tm.fetchInstallationToken(ctx, installationID)
token, expiresAt, err := a.fetchInstallationToken(ctx, installationID)
if err != nil {
return "", errors.Wrap(err, "fetch installation token")
}

tm.mu.Lock()
tm.tokens[org] = &cachedToken{
a.mu.Lock()
a.tokens[org] = &cachedToken{
token: token,
expiresAt: expiresAt,
}
tm.mu.Unlock()
a.mu.Unlock()

logger.InfoContext(ctx, "GitHub App token refreshed",
slog.Time("expires_at", expiresAt))

return token, nil
}

func (tm *TokenManager) GetTokenForURL(ctx context.Context, url string) (string, error) {
if tm == nil {
return "", errors.New("token manager not initialized")
}
org, err := extractOrgFromURL(url)
if err != nil {
return "", err
}

return tm.GetTokenForOrg(ctx, org)
}

func (tm *TokenManager) fetchInstallationToken(ctx context.Context, installationID string) (string, time.Time, error) {
jwt, err := tm.jwtGenerator.GenerateJWT()
func (a *appState) fetchInstallationToken(ctx context.Context, installationID string) (string, time.Time, error) {
jwt, err := a.jwtGenerator.GenerateJWT()
if err != nil {
return "", time.Time{}, errors.Wrap(err, "generate JWT")
}
Expand All @@ -137,7 +172,7 @@ func (tm *TokenManager) fetchInstallationToken(ctx context.Context, installation
req.Header.Set("Authorization", "Bearer "+jwt)
req.Header.Set("X-Github-Api-Version", "2022-11-28")

resp, err := tm.httpClient.Do(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return "", time.Time{}, errors.Wrap(err, "execute request")
}
Expand Down
Loading