agent-orchestrator/backend/internal/adapters/scm/github/auth.go

178 lines
4.7 KiB
Go

package github
import (
"context"
"errors"
"os"
"os/exec"
"strings"
"sync"
"time"
)
// TokenSource yields a GitHub bearer token on demand. Production wires this
// to EnvTokenSource or GHTokenSource; tests inject StaticTokenSource.
type TokenSource interface {
Token(ctx context.Context) (string, error)
}
// tokenInvalidator is the optional capability of dropping a cached token so
// the next call re-fetches it. The Client invokes this whenever GitHub
// responds with an auth-class failure: the next request will pick up a
// rotated token without restarting the daemon.
type tokenInvalidator interface {
InvalidateToken()
}
// ErrNoToken is returned when no token source could yield a non-empty token.
var ErrNoToken = errors.New("github scm: no token configured")
// StaticTokenSource is a literal token, typically used in tests.
type StaticTokenSource string
// Token returns the literal token, or ErrNoToken if it is blank.
func (s StaticTokenSource) Token(context.Context) (string, error) {
t := strings.TrimSpace(string(s))
if t == "" {
return "", ErrNoToken
}
return t, nil
}
// EnvTokenSource reads the first non-empty value from the listed env vars,
// falling back to GITHUB_TOKEN. Order matters: a project-scoped variable
// (AO_GITHUB_TOKEN) should win over the global default.
type EnvTokenSource struct {
EnvVars []string
}
// Token returns the first non-empty env-var value found, or ErrNoToken.
func (s EnvTokenSource) Token(context.Context) (string, error) {
for _, name := range s.EnvVars {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
return v, nil
}
}
if v := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")); v != "" {
return v, nil
}
return "", ErrNoToken
}
// FallbackTokenSource tries each source in order, returning the first token. A
// source that returns ErrNoToken is skipped; other errors are remembered and
// surfaced if no later source yields a token.
type FallbackTokenSource []TokenSource
// Token returns the first non-empty token from the configured sources.
func (s FallbackTokenSource) Token(ctx context.Context) (string, error) {
var firstErr error
for _, src := range s {
if src == nil {
continue
}
tok, err := src.Token(ctx)
if err == nil {
return tok, nil
}
if errors.Is(err, ErrNoToken) {
continue
}
if firstErr == nil {
firstErr = err
}
}
if firstErr != nil {
return "", firstErr
}
return "", ErrNoToken
}
// InvalidateToken forwards cache invalidation to sources that support it.
func (s FallbackTokenSource) InvalidateToken() {
for _, src := range s {
if inv, ok := src.(tokenInvalidator); ok {
inv.InvalidateToken()
}
}
}
const defaultGHTokenCacheTTL = 5 * time.Minute
// GHTokenSource shells out to `gh auth token` when env vars are not
// configured. It memoizes the result for TokenTTL so we don't fork-exec on
// every request, but the Client invalidates the cache on auth failures so a
// rotated token is picked up on the next call. Tests inject GH so the gh
// binary is never required.
type GHTokenSource struct {
// GH is the shell-out hook. Production leaves this nil and falls back
// to `exec.CommandContext("gh", "auth", "token")`; tests inject a
// fake to avoid touching the real binary.
GH func(ctx context.Context) (string, error)
// TokenTTL is how long a successful read is memoized. Zero means use
// defaultGHTokenCacheTTL.
TokenTTL time.Duration
// Clock allows tests to drive expiration. Zero means time.Now.
Clock func() time.Time
mu sync.Mutex
token string
expiresAt time.Time
}
// Token returns the cached token if still fresh, otherwise re-runs gh.
func (s *GHTokenSource) Token(ctx context.Context) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
if s.token != "" && now.Before(s.expiresAt) {
return s.token, nil
}
run := s.GH
if run == nil {
run = ghAuthToken
}
out, err := run(ctx)
if err != nil {
return "", err
}
token := strings.TrimSpace(out)
if token == "" {
return "", ErrNoToken
}
s.token = token
s.expiresAt = now.Add(s.ttl())
return token, nil
}
// InvalidateToken drops the memoized token so the next Token call shells
// out again. The Client calls this on 401/403-auth responses.
func (s *GHTokenSource) InvalidateToken() {
s.mu.Lock()
defer s.mu.Unlock()
s.token = ""
s.expiresAt = time.Time{}
}
func (s *GHTokenSource) now() time.Time {
if s.Clock != nil {
return s.Clock()
}
return time.Now()
}
func (s *GHTokenSource) ttl() time.Duration {
if s.TokenTTL > 0 {
return s.TokenTTL
}
return defaultGHTokenCacheTTL
}
func ghAuthToken(ctx context.Context) (string, error) {
out, err := exec.CommandContext(ctx, "gh", "auth", "token").Output()
if err != nil {
return "", err
}
return string(out), nil
}