auth: remove contexts under a single locked Modify · Entire

auth: remove contexts under a single locked Modify

5306712→main·

toothbrush·1mo ago·1 file·+30 added/-28

Review finding: the logout-failsafe rework split context removal into a Load and a separate Modify, reopening the read/write lock gap the contexts package warns about — a concurrent auth use in the window could retarget which context "current" means mid-logout. Selection, keyring deletion, and entry removal now all run inside one locked Modify; a failed credential delete aborts the callback, which discards the entry change and preserves the retry-friendly failure mode.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

Sessions

02cd9554697cView transcript

Changes

1

12 unmodified lines

// contexts.json entry, clearing current_context. It is a no-op (returns nil)
// when there is no current context. Used by logout.
func RemoveCurrentContext() error {
    f, err := contexts.Load(userdirs.Config())
    if err != nil {
        if err := removeContextLocked(func(f *contexts.File) *contexts.Context {
            return f.Find(f.CurrentContext)
        }); err != nil {
            return fmt.Errorf("remove current context: %w", err)
        }
        current := f.Find(f.CurrentContext)
        if current == nil {
            return nil
        }
        return RemoveContext(current.Name)
    }
    return nil
}

// RemoveContext deletes the named context's keyring tokens, then its
// contexts.json entry. A missing context is a no-op. Used by logout and
// `logout --all-contexts`. File.Delete clears current_context when name was
// the active one, so removing the current context this way also logs it out.
func RemoveContext(name string) error {
    if err := removeContextLocked(func(f *contexts.File) *contexts.Context {
        return f.Find(name)
    }); err != nil {
        return fmt.Errorf("remove context %q: %w", name, err)
    }
    return nil
}

// removeContextLocked deletes the context selected by pick — keyring slots
// first, then the contexts.json entry — inside a single locked Modify, so
// selection, credential deletion, and entry removal can't interleave with a
// concurrent `auth use` or login. A nil pick result is a no-op.

// Credential deletion comes first and is part of the success contract:
// removing the entry and then failing the keyring delete would report
// "Logged out." while the long-lived refresh token survives on the machine,
// mintable by any keyring-capable process. The inverse partial failure
// (slots gone, entry left) is benign — the context reads as not logged in
// mintable by any keyring-capable process. A delete error aborts the Modify,
// leaving the entry intact for a retry. The inverse partial failure (slots
// deleted, entry write fails) is benign — the context reads as not logged in
// and a retried logout no-ops the deletes.
func RemoveContext(name string) error {
    f, err := contexts.Load(userdirs.Config())
    if err != nil {
        return fmt.Errorf("remove context %q: %w", name, err)
    }
    c := f.Find(name)
    if c == nil {
        return nil
    }
    if err := deleteContextKeychain(c.KeychainService, c.Handle); err != nil {
        return fmt.Errorf("remove credentials for %q: %w", name, err)
    }
    if err := contexts.Modify(userdirs.Config(), func(f *contexts.File) (bool, error) {
        if f.Find(name) == nil {
            return false, nil
        }
        f.Delete(name)
        if err := deleteContextKeychain(c.KeychainService, c.Handle); err != nil {
            return false, fmt.Errorf("remove credentials for %q: %w", c.Name, err)
        }
        f.Delete(c.Name)
        return true, nil
    }); err != nil {
        return fmt.Errorf("remove context %q: %w", name, err)
    }
    return nil
}
// deleteContextKeychain removes a context's keyring slots. A missing entry is a no-op.