256 lines
7.5 KiB
Go
256 lines
7.5 KiB
Go
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
|
|
}
|