generated from block/oss-project-template
-
Notifications
You must be signed in to change notification settings - Fork 2
feat: support multiple GitHub Apps for per-org authentication #163
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
js-murph
wants to merge
1
commit into
main
Choose a base branch
from
johnm/multiple-github-apps
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||
| } | ||
|
|
@@ -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") | ||
| } | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.