mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* fix(auth): propagate credentials to every provider-build surface and pin children to the live provider Sub-agents, swarm members, ACP sessions, and mid-run escalation switches could build providers without the credentials the parent was running with. This consolidates credential resolution so every surface gets the same auth: - Wrap deps.newProvider once in fillAppDeps to fold the stored API key in centrally; ACP, exec escalation, and buildProvider no longer patch it per call site (an apiKeyStored profile previously went out keyless on those paths and 401'd). - Export ZERO_PROVIDER at startup (buildProvider) and keep exporting on every runtime switch (wizard, onboarding, /model, /provider), so spawned children resolve the parent's live provider instead of whatever another process last persisted to config.json. Injected via appDeps so tests never mutate the process environment. - Share one OAuth login selection end to end: ProviderProfile. OAuthLoginCandidates (name, then catalog-ID fallback; nothing for a profile with its own configured credential) + oauth.FirstStored resolve the login ONCE, and the bound store key is threaded through providers.Options.OAuthLoginKey so the Codex account-id header always reads the same login that issued the bearer token. A renamed profile (name != catalog ID) now finds its login; an explicit API key is never silently overridden by a same-name or catalog-shared OAuth login. - Export ProviderProfile.HasConfiguredCredential as the single "profile is key-authed" predicate (cli/tui/config previously had three copies). - applyProviderWizard commits live state (provider, profile, name, env export) only after BOTH the build and the config persist succeed, so a persist failure no longer leaves the chat running on a provider the status line and children disagree with. Covered by red-green tests for each path: stored-key wrap, escalation switch, catalog-ID fallback (incl. case-variant names), explicit-key protection, same-login account selection, wizard persist-failure rollback, onboarding/startup env export. * test: isolate OAuth/config test storage and fix the flaky MCP response-id test Review + CI fixes, all test-only: - factory/cli OAuth tests: pin ZERO_OAUTH_STORAGE=file next to the temp ZERO_OAUTH_TOKENS_PATH — an inherited "keyring" value sends NewStore to the OS keychain and ignores the temp path entirely, so the tests could read (or clobber) the developer's real logins. newOAuthStoreForTest now owns both pins so a future call site can't forget them. - TestApplyProviderWizardExportsActiveProviderEnv: point m.userConfigPath at a temp config and stub newProvider, so the SUCCESS path exercises the full build+persist+commit sequence in isolation (the default empty path silently skipped persist; a changed default must never reach the real user config). - TestClientRequestWaitsForMatchingResponseID (ubuntu smoke flake): the responses were pre-loaded into a bytes.Buffer, so the client's reader goroutine could consume the matching response — and hit EOF — before request() registered its pending id, failing with "request() error = EOF" on fast runners. The fake server now sits on pipes and responds only after reading the outgoing request, matching production ordering; survives -race -count=50.
526 lines
16 KiB
Go
526 lines
16 KiB
Go
package oauth
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/keyring"
|
|
)
|
|
|
|
const (
|
|
storeSchemaVersion = 1
|
|
// KeyPrefixProvider namespaces provider-login tokens; MCP server tokens live
|
|
// under KeyPrefixMCP in the same format (so a future MCP migration is a key
|
|
// rename, not a format change).
|
|
KeyPrefixProvider = "provider:"
|
|
KeyPrefixMCP = "mcp:"
|
|
)
|
|
|
|
// keyPattern bounds a token key to a safe, single-segment namespaced identifier
|
|
// so a key can never traverse or collide with store internals.
|
|
var keyPattern = regexp.MustCompile(`^(provider|mcp):[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
|
|
|
// ValidateKey reports whether key is a well-formed namespaced token key.
|
|
func ValidateKey(key string) error {
|
|
if !keyPattern.MatchString(key) {
|
|
return fmt.Errorf("oauth: invalid token key %q (want \"provider:<name>\" or \"mcp:<name>\")", key)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ProviderKey builds the store key for a provider login.
|
|
func ProviderKey(name string) string { return KeyPrefixProvider + name }
|
|
|
|
// FirstStored returns the token and its ProviderKey for the FIRST candidate name
|
|
// that has a token in the store, with ok=false when none do. Callers pass
|
|
// ProviderProfile.OAuthLoginCandidates() so that everything derived from a login
|
|
// — the bearer token AND any header claim like chatgpt-account-id — comes from
|
|
// the SAME login; selecting independently per consumer could otherwise pair a
|
|
// bearer from one login with an account header from another. A load error on a
|
|
// candidate is treated as a miss (skip to the next), never a hard failure.
|
|
func FirstStored(store *Store, candidates []string) (Token, string, bool) {
|
|
if store == nil {
|
|
return Token{}, "", false
|
|
}
|
|
for _, name := range candidates {
|
|
key := ProviderKey(name)
|
|
if token, ok, err := store.Load(key); err == nil && ok {
|
|
return token, key, true
|
|
}
|
|
}
|
|
return Token{}, "", false
|
|
}
|
|
|
|
// Status is a redaction-safe summary of a stored token (no secret material).
|
|
type Status struct {
|
|
Key string `json:"key"`
|
|
HasToken bool `json:"hasToken"`
|
|
HasRefreshToken bool `json:"hasRefreshToken"`
|
|
TokenType string `json:"tokenType,omitempty"`
|
|
Account string `json:"account,omitempty"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
|
Expired bool `json:"expired"`
|
|
}
|
|
|
|
// StoreOptions configures where provider OAuth tokens are persisted.
|
|
type StoreOptions struct {
|
|
FilePath string
|
|
Env map[string]string
|
|
Now func() time.Time
|
|
// Storage selects the backend: "" / "file" => a 0600 JSON file (default);
|
|
// "encrypted-file" => an AES-256-GCM encrypted file; "keyring" => the OS
|
|
// keyring. When empty it falls back to ZERO_OAUTH_STORAGE.
|
|
Storage string
|
|
// Encrypted is a legacy alias for Storage=="encrypted-file" (AES-256-GCM at
|
|
// rest). Ignored when Storage is set.
|
|
Encrypted bool
|
|
// Keyring is the client used when Storage=="keyring"; nil => keyring.New().
|
|
// Injected by tests to avoid touching a real keychain.
|
|
Keyring KeyringClient
|
|
}
|
|
|
|
// KeyringClient is the minimal OS-keyring surface the store needs. *keyring.Keyring
|
|
// satisfies it; tests inject a fake.
|
|
type KeyringClient interface {
|
|
Get(service, account string) (string, bool, error)
|
|
Set(service, account, secret string) error
|
|
Delete(service, account string) (bool, error)
|
|
}
|
|
|
|
// Keyring storage stores the whole token blob under one fixed entry.
|
|
const (
|
|
keyringService = "zero"
|
|
keyringAccount = "oauth-tokens"
|
|
)
|
|
|
|
// Store persists OAuth tokens (provider + MCP namespaces) as one JSON blob,
|
|
// written atomically through a pluggable backend (a 0600 file guarded by a
|
|
// cross-process lock, or the OS keyring). When crypter is non-nil the file blob
|
|
// is AES-256-GCM ciphertext at rest.
|
|
type Store struct {
|
|
blob blobStore
|
|
crypter *aesGCMCrypter // nil => plaintext blob
|
|
now func() time.Time
|
|
mu sync.Mutex
|
|
}
|
|
|
|
type storeFile struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Tokens map[string]Token `json:"tokens"`
|
|
}
|
|
|
|
// ResolveStorePath determines the on-disk location for provider OAuth tokens,
|
|
// honoring ZERO_OAUTH_TOKENS_PATH, then XDG_CONFIG_HOME, then the home dir.
|
|
func ResolveStorePath(env map[string]string) (string, error) {
|
|
if override := strings.TrimSpace(envValue(env, "ZERO_OAUTH_TOKENS_PATH")); override != "" {
|
|
if filepath.IsAbs(override) {
|
|
return filepath.Clean(override), nil
|
|
}
|
|
return filepath.Abs(override)
|
|
}
|
|
configHome := strings.TrimSpace(envValue(env, "XDG_CONFIG_HOME"))
|
|
if configHome == "" {
|
|
home := strings.TrimSpace(firstNonEmpty(envValue(env, "HOME"), envValue(env, "USERPROFILE")))
|
|
if home == "" {
|
|
var err error
|
|
home, err = os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("oauth: resolve user home: %w", err)
|
|
}
|
|
}
|
|
configHome = filepath.Join(home, ".config")
|
|
} else if !filepath.IsAbs(configHome) {
|
|
resolved, err := filepath.Abs(configHome)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
configHome = resolved
|
|
}
|
|
return filepath.Join(configHome, "zero", "oauth-tokens.json"), nil
|
|
}
|
|
|
|
// NewStore builds a token store with the configured backend (file by default,
|
|
// or the OS keyring when Storage/ZERO_OAUTH_STORAGE selects it).
|
|
func NewStore(options StoreOptions) (*Store, error) {
|
|
now := options.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
storage := strings.TrimSpace(options.Storage)
|
|
if storage == "" {
|
|
storage = strings.TrimSpace(envValue(options.Env, "ZERO_OAUTH_STORAGE"))
|
|
}
|
|
if storage == "" && options.Encrypted {
|
|
storage = "encrypted-file" // legacy alias
|
|
}
|
|
switch storage {
|
|
case "", "file":
|
|
path, err := resolveStoreFilePath(options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Store{blob: fileBlob{path: path}, now: now}, nil
|
|
case "encrypted-file":
|
|
path, err := resolveStoreFilePath(options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The file blob holds AES-256-GCM ciphertext; the per-user secret lives in
|
|
// a sibling ".secret" file (see encrypt.go).
|
|
return &Store{blob: fileBlob{path: path}, crypter: newAESGCMCrypter(path + ".secret"), now: now}, nil
|
|
case "keyring":
|
|
kr := options.Keyring
|
|
if kr == nil {
|
|
osKeyring := keyring.New()
|
|
if !osKeyring.Available() {
|
|
return nil, fmt.Errorf("oauth: keyring storage requested but not available on %s; use file storage", runtime.GOOS)
|
|
}
|
|
kr = osKeyring
|
|
}
|
|
// Serialize the keyring's read-modify-write across processes with a lock
|
|
// file beside where the file backend would live. Best-effort: if no config
|
|
// location resolves, fall back to in-process serialization only.
|
|
lockPath := ""
|
|
if storePath, perr := ResolveStorePath(options.Env); perr == nil {
|
|
lockPath = filepath.Join(filepath.Dir(storePath), "oauth-keyring.lockfile")
|
|
}
|
|
return &Store{blob: keyringBlob{kr: kr, service: keyringService, account: keyringAccount, lockPath: lockPath}, now: now}, nil
|
|
default:
|
|
return nil, fmt.Errorf("oauth: unknown storage %q (want \"file\", \"encrypted-file\", or \"keyring\")", storage)
|
|
}
|
|
}
|
|
|
|
// resolveStoreFilePath resolves the absolute file path for the file backend.
|
|
func resolveStoreFilePath(options StoreOptions) (string, error) {
|
|
filePath := options.FilePath
|
|
var err error
|
|
if strings.TrimSpace(filePath) == "" {
|
|
filePath, err = ResolveStorePath(options.Env)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
if !filepath.IsAbs(filePath) {
|
|
filePath, err = filepath.Abs(filePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
return filepath.Clean(filePath), nil
|
|
}
|
|
|
|
// FilePath returns the resolved token store location (a path for the file
|
|
// backend, or a "keyring:..." identifier for the keyring backend).
|
|
func (s *Store) FilePath() string { return s.blob.location() }
|
|
|
|
// Save persists a token under key, replacing any existing entry.
|
|
func (s *Store) Save(key string, token Token) error {
|
|
if err := ValidateKey(key); err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.blob.withLock(s.now, func() error {
|
|
state, err := s.readState()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
state.Tokens[key] = token
|
|
return s.writeState(state)
|
|
})
|
|
}
|
|
|
|
// Load returns the token for key; the bool is false when none is stored.
|
|
func (s *Store) Load(key string) (Token, bool, error) {
|
|
if err := ValidateKey(key); err != nil {
|
|
return Token{}, false, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
state, err := s.readState()
|
|
if err != nil {
|
|
return Token{}, false, err
|
|
}
|
|
token, ok := state.Tokens[key]
|
|
return token, ok, nil
|
|
}
|
|
|
|
// Delete removes the token for key, reporting whether one was present.
|
|
func (s *Store) Delete(key string) (bool, error) {
|
|
if err := ValidateKey(key); err != nil {
|
|
return false, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var removed bool
|
|
err := s.blob.withLock(s.now, func() error {
|
|
state, err := s.readState()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, ok := state.Tokens[key]; !ok {
|
|
return nil
|
|
}
|
|
delete(state.Tokens, key)
|
|
removed = true
|
|
return s.writeState(state)
|
|
})
|
|
return removed, err
|
|
}
|
|
|
|
// Status returns redaction-safe summaries of every stored token, sorted by key.
|
|
// An optional prefix filters to one namespace (e.g. KeyPrefixProvider).
|
|
func (s *Store) Status(prefix string) ([]Status, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
state, err := s.readState()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keys := make([]string, 0, len(state.Tokens))
|
|
for k := range state.Tokens {
|
|
if prefix == "" || strings.HasPrefix(k, prefix) {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
now := s.now()
|
|
out := make([]Status, 0, len(keys))
|
|
for _, k := range keys {
|
|
token := state.Tokens[k]
|
|
out = append(out, Status{
|
|
Key: k,
|
|
HasToken: strings.TrimSpace(token.AccessToken) != "",
|
|
HasRefreshToken: strings.TrimSpace(token.RefreshToken) != "",
|
|
TokenType: token.TokenType,
|
|
Account: token.Account,
|
|
Scopes: token.Scopes,
|
|
ExpiresAt: token.ExpiresAt,
|
|
Expired: token.Expired(now),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) readState() (storeFile, error) {
|
|
data, ok, err := s.blob.read()
|
|
if err != nil {
|
|
return storeFile{}, err
|
|
}
|
|
if !ok {
|
|
return emptyStoreFile(), nil
|
|
}
|
|
if s.crypter != nil {
|
|
// Encrypted backend: the blob is AES-256-GCM ciphertext, not JSON.
|
|
data, err = s.crypter.open(data)
|
|
if err != nil {
|
|
return storeFile{}, fmt.Errorf("oauth: decrypt token store at %s: %w", s.blob.location(), err)
|
|
}
|
|
}
|
|
var state storeFile
|
|
if err := json.Unmarshal(data, &state); err != nil {
|
|
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: %w", s.blob.location(), err)
|
|
}
|
|
if state.SchemaVersion != storeSchemaVersion {
|
|
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: unsupported schemaVersion", s.blob.location())
|
|
}
|
|
if state.Tokens == nil {
|
|
state.Tokens = map[string]Token{}
|
|
}
|
|
for key := range state.Tokens {
|
|
if err := ValidateKey(key); err != nil {
|
|
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: %w", s.blob.location(), err)
|
|
}
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
func (s *Store) writeState(state storeFile) error {
|
|
data, err := json.MarshalIndent(state, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Plaintext keeps the trailing newline for a tidy file; the encrypted backend
|
|
// writes opaque ciphertext instead.
|
|
payload := append(data, '\n')
|
|
if s.crypter != nil {
|
|
payload, err = s.crypter.seal(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return s.blob.write(payload)
|
|
}
|
|
|
|
func emptyStoreFile() storeFile {
|
|
return storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{}}
|
|
}
|
|
|
|
// blobStore abstracts the persistence of the whole token blob behind the Store,
|
|
// so the same store logic backs either a 0600 file or the OS keyring.
|
|
type blobStore interface {
|
|
// read returns the stored blob; ok is false when nothing is stored yet.
|
|
read() (data []byte, ok bool, err error)
|
|
// write replaces the stored blob.
|
|
write(data []byte) error
|
|
// withLock runs fn under whatever cross-process exclusion the backend offers
|
|
// (a lock file for the file backend; none for the keyring, which is the
|
|
// authoritative store and is serialized within the process by Store.mu).
|
|
withLock(now func() time.Time, fn func() error) error
|
|
// location is a human-readable identifier for diagnostics/errors.
|
|
location() string
|
|
}
|
|
|
|
// fileBlob persists the blob as a 0600 JSON file, written atomically and guarded
|
|
// by a cross-process lock file. Behavior matches the original file store.
|
|
type fileBlob struct{ path string }
|
|
|
|
func (b fileBlob) read() ([]byte, bool, error) {
|
|
data, err := os.ReadFile(b.path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, false, nil
|
|
}
|
|
return nil, false, err
|
|
}
|
|
return data, true, nil
|
|
}
|
|
|
|
func (b fileBlob) write(data []byte) error {
|
|
if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil {
|
|
return err
|
|
}
|
|
tempPath := fmt.Sprintf("%s.tmp-%d-%d", b.path, os.Getpid(), time.Now().UnixNano())
|
|
if err := os.WriteFile(tempPath, data, 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tempPath, b.path); err != nil {
|
|
_ = os.Remove(tempPath)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b fileBlob) withLock(now func() time.Time, fn func() error) error {
|
|
unlock, err := acquireFileLock(b.path+".lockfile", now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
return fn()
|
|
}
|
|
|
|
func (b fileBlob) location() string { return b.path }
|
|
|
|
// keyringBlob persists the blob in the OS keyring as a single base64 entry
|
|
// (base64 keeps the multi-line JSON a single, control-character-free value).
|
|
type keyringBlob struct {
|
|
kr KeyringClient
|
|
service string
|
|
account string
|
|
// lockPath, when set, is a cross-process lock file serializing the keyring's
|
|
// read-modify-write so concurrent processes don't clobber each other's tokens.
|
|
lockPath string
|
|
}
|
|
|
|
func (b keyringBlob) read() ([]byte, bool, error) {
|
|
enc, ok, err := b.kr.Get(b.service, b.account)
|
|
if err != nil || !ok {
|
|
return nil, ok, err
|
|
}
|
|
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc))
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("oauth: decode keyring token blob: %w", err)
|
|
}
|
|
return data, true, nil
|
|
}
|
|
|
|
func (b keyringBlob) write(data []byte) error {
|
|
return b.kr.Set(b.service, b.account, base64.StdEncoding.EncodeToString(data))
|
|
}
|
|
|
|
// withLock serializes the keyring's read-modify-write. Store.mu covers the
|
|
// in-process case; lockPath (when set) adds cross-process exclusion so two
|
|
// processes can't both read the blob, modify, and write — dropping a token.
|
|
func (b keyringBlob) withLock(now func() time.Time, fn func() error) error {
|
|
if b.lockPath == "" {
|
|
return fn()
|
|
}
|
|
unlock, err := acquireFileLock(b.lockPath, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
return fn()
|
|
}
|
|
|
|
func (b keyringBlob) location() string { return "keyring:" + b.service + "/" + b.account }
|
|
|
|
// FormatStatuses renders a human-readable status table without leaking token
|
|
// material.
|
|
func FormatStatuses(statuses []Status) string {
|
|
if len(statuses) == 0 {
|
|
return "No OAuth provider logins are stored."
|
|
}
|
|
var b strings.Builder
|
|
for i, st := range statuses {
|
|
if i > 0 {
|
|
b.WriteByte('\n')
|
|
}
|
|
name := strings.TrimPrefix(st.Key, KeyPrefixProvider)
|
|
b.WriteString(name)
|
|
b.WriteString(": ")
|
|
if !st.HasToken {
|
|
b.WriteString("no token")
|
|
continue
|
|
}
|
|
b.WriteString("logged in")
|
|
if st.Account != "" {
|
|
b.WriteString(" as " + st.Account)
|
|
}
|
|
if st.HasRefreshToken {
|
|
b.WriteString(" (refreshable)")
|
|
}
|
|
if !st.ExpiresAt.IsZero() {
|
|
if st.Expired {
|
|
b.WriteString(", expired at ")
|
|
} else {
|
|
b.WriteString(", expires ")
|
|
}
|
|
b.WriteString(st.ExpiresAt.UTC().Format(time.RFC3339))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// envValue reads a variable. A non-nil env map is authoritative (hermetic): a
|
|
// missing key returns "" rather than falling back to the process environment, so
|
|
// a caller/test that passes a controlled map can never pick up ambient
|
|
// ZERO_OAUTH_* / HOME / XDG_CONFIG_HOME values. Only a nil map uses os.Getenv.
|
|
func envValue(env map[string]string, key string) string {
|
|
if env != nil {
|
|
return env[key]
|
|
}
|
|
return os.Getenv(key)
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
if strings.TrimSpace(v) != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|