package github import ( "context" "errors" "fmt" "sort" "strings" "sync" "time" "golang.org/x/sync/singleflight" ) const ( installationTokenRefreshMargin = 5 * time.Minute installationTokenMintTimeout = 30 * time.Second ) var ( ErrInstallationTokenExpired = errors.New("GitHub App installation token is expired") ErrInstallationTokenInvalidated = errors.New("GitHub App installation token was invalidated") ) // InstallationTokenMinter mints one permission-scoped token. type InstallationTokenMinter interface { MintInstallationToken(context.Context, int64, InstallationPermissions, []string) (InstallationToken, error) } type installationTokenCacheKey struct { appRegistrationID string appCredentialGeneration int64 workspaceID string installationID int64 permissions string repositories string } type installationTokenCacheEntry struct { token InstallationToken } // InstallationTokenCache keeps GitHub's short-lived installation tokens in // memory and coalesces concurrent refreshes for the same installation/scope. type InstallationTokenCache struct { registrationID string credentialGeneration int64 minter InstallationTokenMinter mu sync.RWMutex entries map[installationTokenCacheKey]installationTokenCacheEntry generations map[int64]uint64 group singleflight.Group now func() time.Time } func NewInstallationTokenCache(minter InstallationTokenMinter) *InstallationTokenCache { return NewAppInstallationTokenCache("", 0, minter) } func NewAppInstallationTokenCache( registrationID string, credentialGeneration int64, minter InstallationTokenMinter, ) *InstallationTokenCache { return &InstallationTokenCache{ registrationID: registrationID, credentialGeneration: credentialGeneration, minter: minter, entries: make(map[installationTokenCacheKey]installationTokenCacheEntry), generations: make(map[int64]uint64), now: time.Now, } } // Get returns a valid token and begins refreshing before expiry. Concurrent // callers for the same installation and permission scope share one mint. func (c *InstallationTokenCache) Get( ctx context.Context, installationID int64, permissions InstallationPermissions, repositories []string, ) (InstallationToken, error) { return c.GetForWorkspace(ctx, "", installationID, permissions, repositories) } func (c *InstallationTokenCache) GetForWorkspace( ctx context.Context, workspaceID string, installationID int64, permissions InstallationPermissions, repositories []string, ) (InstallationToken, error) { if c == nil || c.minter == nil { return InstallationToken{}, errors.New("GitHub App installation token minter is not configured") } permissions = clonePermissions(permissions) repositories = canonicalRepositories(repositories) key := installationTokenCacheKey{ appRegistrationID: c.registrationID, appCredentialGeneration: c.credentialGeneration, workspaceID: workspaceID, installationID: installationID, permissions: canonicalPermissions(permissions), repositories: strings.Join(repositories, ","), } if token, ok := c.cached(key, installationTokenRefreshMargin); ok { return token, nil } generation := c.generation(installationID) flightKey := fmt.Sprintf( "%s:%d:%s:%d:%d:%s:%s", c.registrationID, c.credentialGeneration, workspaceID, installationID, generation, key.permissions, key.repositories, ) result := c.refresh(flightKey, ctx, key, installationID, generation, permissions, repositories) select { case <-ctx.Done(): return InstallationToken{}, ctx.Err() case resolved := <-result: return c.resolveRefreshResult(key, resolved) } } func (c *InstallationTokenCache) refresh( flightKey string, ctx context.Context, key installationTokenCacheKey, installationID int64, generation uint64, permissions InstallationPermissions, repositories []string, ) <-chan singleflight.Result { return c.group.DoChan(flightKey, func() (any, error) { if token, ok := c.cached(key, installationTokenRefreshMargin); ok { return token, nil } mintCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), installationTokenMintTimeout) defer cancel() token, err := c.minter.MintInstallationToken( mintCtx, installationID, clonePermissions(permissions), append([]string(nil), repositories...), ) if err != nil { return InstallationToken{}, err } if token.Token == "" || !token.ExpiresAt.After(c.now()) { return InstallationToken{}, ErrInstallationTokenExpired } token.Principal.AppRegistrationID = c.registrationID token.Principal.AppCredentialGeneration = c.credentialGeneration c.mu.Lock() defer c.mu.Unlock() if c.generations[installationID] != generation { return InstallationToken{}, ErrInstallationTokenInvalidated } c.entries[key] = installationTokenCacheEntry{token: cloneInstallationToken(token)} return token, nil }) } func (c *InstallationTokenCache) resolveRefreshResult( key installationTokenCacheKey, resolved singleflight.Result, ) (InstallationToken, error) { if resolved.Err != nil { if !errors.Is(resolved.Err, ErrInstallationTokenInvalidated) { if token, ok := c.cached(key, 0); ok { return token, nil } } return InstallationToken{}, resolved.Err } token, ok := resolved.Val.(InstallationToken) if !ok || token.Token == "" || !token.ExpiresAt.After(c.now()) { return InstallationToken{}, ErrInstallationTokenExpired } return cloneInstallationToken(token), nil } // Invalidate prevents cached or in-flight results for an installation from // being reused after disconnect, suspension, revocation, or replacement. func (c *InstallationTokenCache) Invalidate(installationID int64) { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() c.generations[installationID]++ for key := range c.entries { if key.installationID == installationID { delete(c.entries, key) } } } func (c *InstallationTokenCache) generation(installationID int64) uint64 { c.mu.RLock() defer c.mu.RUnlock() return c.generations[installationID] } func (c *InstallationTokenCache) cached( key installationTokenCacheKey, refreshMargin time.Duration, ) (InstallationToken, bool) { c.mu.RLock() entry, ok := c.entries[key] c.mu.RUnlock() if !ok || entry.token.Token == "" || !entry.token.ExpiresAt.After(c.now().Add(refreshMargin)) { return InstallationToken{}, false } return cloneInstallationToken(entry.token), true } func canonicalPermissions(permissions InstallationPermissions) string { keys := make([]string, 0, len(permissions)) for name := range permissions { keys = append(keys, name) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, name := range keys { parts = append(parts, name+"="+string(permissions[name])) } return strings.Join(parts, ",") } func canonicalRepositories(repositories []string) []string { unique := make(map[string]struct{}, len(repositories)) for _, repository := range repositories { repository = strings.ToLower(strings.TrimSpace(repository)) if repository != "" { unique[repository] = struct{}{} } } result := make([]string, 0, len(unique)) for repository := range unique { result = append(result, repository) } sort.Strings(result) return result } func clonePermissions(permissions InstallationPermissions) InstallationPermissions { if permissions == nil { return nil } cloned := make(InstallationPermissions, len(permissions)) for name, level := range permissions { cloned[name] = level } return cloned } func cloneInstallationToken(token InstallationToken) InstallationToken { token.Permissions = clonePermissions(token.Permissions) return token }