250 lines
8.3 KiB
Go
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) != ""
|
|
}
|