Files
2026-08-14 23:41:57 +08:00

250 lines
8.3 KiB
Go

// This file implements credential resolution (US-012): API key lookup from
// environment variables and a config file (per provider), plus an OAuth token
// source that refreshes short-lived tokens on expiry. The resolver satisfies
// the LoopConfig.GetAPIKey shape (func(ctx, provider) string) so the agent loop
// can obtain a fresh key per request.
//
// Security (FR: secret values are not written to logs): secret values are never logged or embedded in
// error messages. Errors and String()/redaction helpers reference credentials
// by key name / provider only.
package provider
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
)
// APIKeyConfig is the on-disk config-file shape: a map of provider name to API
// key. It is parsed from JSON and holds only static keys (OAuth lives in
// TokenSource). Values are secrets and must not be logged.
type APIKeyConfig struct {
// Keys maps provider name → API key.
Keys map[string]string `json:"keys"`
}
// LoadAPIKeyConfig parses an APIKeyConfig from JSON bytes (e.g. a config file).
func LoadAPIKeyConfig(data []byte) (*APIKeyConfig, error) {
var cfg APIKeyConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("auth: parse api key config: %w", err)
}
if cfg.Keys == nil {
cfg.Keys = make(map[string]string)
}
return &cfg, nil
}
// LoadAPIKeyConfigFile reads and parses an APIKeyConfig from a file path. A
// missing file is not an error — it returns an empty config so env/OAuth can
// still resolve keys.
func LoadAPIKeyConfigFile(path string) (*APIKeyConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &APIKeyConfig{Keys: make(map[string]string)}, nil
}
return nil, fmt.Errorf("auth: read api key config %q: %w", path, err)
}
return LoadAPIKeyConfig(data)
}
// envAPIKey returns the API key for a provider from the environment. It derives
// the candidate variable names from the provider registry (the single source of
// truth: LookupProviderSpec(provider).EnvVars, in precedence order), then falls
// back to a generic <PROVIDER>_API_KEY when the provider is unknown or none of
// its registered vars are set. Returns "" when no value is present.
func envAPIKey(provider string) string {
if spec, ok := LookupProviderSpec(provider); ok {
for _, name := range spec.EnvVars {
if v := os.Getenv(name); v != "" {
return v
}
}
}
// Generic fallback for unknown providers or when no registered var is set.
generic := strings.ToUpper(provider) + "_API_KEY"
return os.Getenv(generic)
}
// TokenSource yields an access token, refreshing it when expired. It models an
// OAuth credential whose access token is short-lived (FR-15: getApiKey refreshes
// on expiry). It is safe for concurrent use.
type TokenSource struct {
mu sync.Mutex
accessToken string
expiry time.Time
refreshToken string
// Refresh exchanges the current refresh token for a new access token. It
// returns the new access token, its expiry, and (optionally) a rotated
// refresh token. Required for a TokenSource to refresh; nil means the token
// is static and never refreshed.
Refresh func(ctx context.Context, refreshToken string) (OAuthToken, error)
// Now is injectable for testing; defaults to time.Now.
Now func() time.Time
// Leeway refreshes the token this long before its actual expiry to avoid
// racing the boundary. Defaults to 30s.
Leeway time.Duration
}
// OAuthToken is the result of an OAuth exchange/refresh. Values are secrets.
type OAuthToken struct {
AccessToken string
RefreshToken string
Expiry time.Time
}
// NewTokenSource builds a TokenSource seeded with an initial token and a refresh
// function. refresh may be nil for a static (never-expiring) token.
func NewTokenSource(initial OAuthToken, refresh func(ctx context.Context, refreshToken string) (OAuthToken, error)) *TokenSource {
return &TokenSource{
accessToken: initial.AccessToken,
expiry: initial.Expiry,
refreshToken: initial.RefreshToken,
Refresh: refresh,
}
}
func (t *TokenSource) now() time.Time {
if t.Now != nil {
return t.Now()
}
return time.Now()
}
// defaultTokenLeeway is how far before an OAuth token's expiry it is treated as
// already expired, so a refresh happens before a request rather than mid-flight.
const defaultTokenLeeway = 30 * time.Second
func (t *TokenSource) leeway() time.Duration {
if t.Leeway > 0 {
return t.Leeway
}
return defaultTokenLeeway
}
// expired reports whether the access token is missing or within leeway of its
// expiry. A zero expiry means "never expires" (static token).
func (t *TokenSource) expired() bool {
if t.accessToken == "" {
return true
}
if t.expiry.IsZero() {
return false
}
return !t.now().Before(t.expiry.Add(-t.leeway()))
}
// Token returns a valid access token, refreshing it when expired. It errors if
// a refresh is needed but no Refresh func is set, or if Refresh fails. The
// returned error never contains the token value.
func (t *TokenSource) Token(ctx context.Context) (string, error) {
t.mu.Lock()
defer t.mu.Unlock()
if !t.expired() {
return t.accessToken, nil
}
if t.Refresh == nil {
return "", fmt.Errorf("auth: token expired and no refresh function configured")
}
tok, err := t.Refresh(ctx, t.refreshToken)
if err != nil {
return "", fmt.Errorf("auth: token refresh failed: %w", err)
}
t.accessToken = tok.AccessToken
t.expiry = tok.Expiry
if tok.RefreshToken != "" {
t.refreshToken = tok.RefreshToken
}
return t.accessToken, nil
}
// CredentialStore resolves API keys per provider from three layers, in order:
// OAuth token source (if registered), environment variable, config file. It
// implements the LoopConfig.GetAPIKey shape via GetAPIKey.
//
// It is safe for concurrent use.
type CredentialStore struct {
mu sync.RWMutex
config *APIKeyConfig
sources map[string]*TokenSource // provider → OAuth token source
overrides map[string]string // provider → explicit key (highest static priority)
}
// NewCredentialStore builds a store over an optional config file. A nil config
// is treated as empty.
func NewCredentialStore(config *APIKeyConfig) *CredentialStore {
if config == nil {
config = &APIKeyConfig{Keys: make(map[string]string)}
}
return &CredentialStore{
config: config,
sources: make(map[string]*TokenSource),
overrides: make(map[string]string),
}
}
// SetOverride records an explicit API key for a provider that wins over the
// environment variable and config file (but not a live OAuth token, which is
// auto-refreshed). It is the seam for a CLI --api-key flag: an empty key is
// ignored so a bare flag does not clobber env/config resolution.
func (c *CredentialStore) SetOverride(provider, key string) {
if key == "" {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.overrides[provider] = key
}
// RegisterOAuth registers an OAuth TokenSource for a provider. Once registered,
// GetAPIKey prefers the (auto-refreshing) OAuth token over static keys.
func (c *CredentialStore) RegisterOAuth(provider string, src *TokenSource) {
c.mu.Lock()
defer c.mu.Unlock()
c.sources[provider] = src
}
// GetAPIKey resolves the API key for a provider. Resolution order: OAuth token
// (refreshed on expiry) → explicit override (--api-key) → environment variable
// → config file. Returns "" when no credential is available. This matches
// LoopConfig.GetAPIKey so it can be assigned directly.
//
// On OAuth refresh failure it falls back to override/env/config rather than
// returning a secret-bearing error; the empty return lets the caller fall back
// to a static key. It never logs secret values.
func (c *CredentialStore) GetAPIKey(ctx context.Context, provider string) string {
c.mu.RLock()
src := c.sources[provider]
override := c.overrides[provider]
cfgKey := ""
if c.config != nil {
cfgKey = c.config.Keys[provider]
}
c.mu.RUnlock()
if src != nil {
if tok, err := src.Token(ctx); err == nil && tok != "" {
return tok
}
// Refresh failed → fall through to static layers.
}
if override != "" {
return override
}
if env := envAPIKey(provider); env != "" {
return env
}
return cfgKey
}
// HasCredential reports whether any credential (OAuth/env/config) is available
// for a provider, without exposing the value.
func (c *CredentialStore) HasCredential(ctx context.Context, provider string) bool {
return c.GetAPIKey(ctx, provider) != ""
}