logout: add --all to drain every saved login (context) · Entire

logout: add --all to drain every saved login (context)

3940664·

toothbrush·1mo ago·4 files·+350 added/-13 removed

`entire logout --all` iterates all saved contexts, revokes each context's current session server-side against its own core (or every session on that core when combined with --everywhere), and removes each login locally — then clears the legacy keyring entry so the machine ends fully logged out. Per-context failures warn but never abort the sweep; local removal always proceeds. Adds auth.RemoveContext (named sibling of RemoveCurrentContext, sharing a keychain-cleanup helper). Also fixes the stale --all reference in the logout help left by the --all→--everywhere rename.

Sessions

51b6330bf003View transcript

Changes

4

49 unmodified lines

// RemoveContext deletes the named context from contexts.json and its keyring
tokens. A missing context is a no-op. Used by `logout --all` to drain every
saved login. 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 {
var svc, handle string
if err := contexts.Modify(contexts.DefaultConfigDir(), func(f *contexts.File) (bool, error) {
c := f.Find(name)
if c == nil {
return false, nil
}
svc, handle = c.KeychainService, c.Handle
f.Delete(name)
return true, nil
}); err != nil {
return fmt.Errorf("remove context %q: %w", name, err)
}
deleteContextKeychain(svc, handle)
return nil
}

// deleteContextKeychain best-effort removes a context's keyring slots,
// sequenced off the context just removed from contexts.json. A missing entry
// is fine — the contexts.json removal is what makes us "logged out". Both the
// access slot and its paired refresh slot must go: leaving the long-lived
// refresh token behind would let any later keyring-capable process mint fresh
// access tokens after logout.
func deleteContextKeychain(svc, handle string) {
if svc == "" || handle == "" {
return
}
_ = tokenstore.Delete(svc, handle)                            //nolint:errcheck // best-effort; contexts.json removal is the source of truth for logout
_ = tokenstore.Delete(tokenstore.RefreshService(svc), handle) //nolint:errcheck // best-effort; absent refresh slot is fine
}

// SetCurrentContext makes name the active context. Returns an error when
// no context with that name exists (a stale current pointer is a foot-gun).
func SetCurrentContext(name string) error {

Mcmd/entire/cli/auth/context\_store.go+35/-8

303 unmodified lines

func TestRemoveContext(t *testing.T) { cfgDir := t.TempDir() t.Setenv("ENTIRE_CONFIG_DIR", cfgDir) restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json")) t.Cleanup(restore)

exp := time.Now().Add(time.Hour).Unix() first, err := RecordLoginContext(makeJWT(t, fmt.Sprintf({"iss":"https://a.example.com","handle":"alice","exp":%d}, exp)), "entr_a", true) if err != nil { t.Fatalf("record a: %v", err) } active, err := RecordLoginContext(makeJWT(t, fmt.Sprintf({"iss":"https://b.example.com","handle":"alice","exp":%d}, exp)), "entr_b", true) if err != nil { t.Fatalf("record b: %v", err) }

// Remove the non-current context by name: it must disappear (both slots) // while the active context and current_context pointer are untouched. if err := RemoveContext(first); err != nil { t.Fatalf("RemoveContext: %v", err) } f, err := contexts.Load(cfgDir) if err != nil { t.Fatalf("load: %v", err) } if f.Find(first) != nil { t.Fatalf("context %q should have been removed", first) } if f.CurrentContext != active { t.Fatalf("current_context = %q, want the untouched active context %q", f.CurrentContext, active) } svcA := tokenstore.CoreKeyringService("https://a.example.com") if v, err := tokenstore.Get(svcA, "alice"); !errors.Is(err, tokenstore.ErrNotFound) { t.Fatalf("access slot survived RemoveContext: value=%q err=%v", v, err) } if v, err := tokenstore.Get(tokenstore.RefreshService(svcA), "alice"); !errors.Is(err, tokenstore.ErrNotFound) { t.Fatalf("refresh slot survived RemoveContext: value=%q err=%v", v, err) }

// Idempotent: removing a name that no longer exists is a no-op. if err := RemoveContext(first); err != nil { t.Fatalf("second RemoveContext: %v", err) } }

// TestSetCurrentContext tests the setting of the current context. func TestSetCurrentContext(t *testing.T) { cfgDir := t.TempDir() t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)

Mcmd/entire/cli/auth/contexts_test.go+45

7 unmodified lines

func newLogoutCmd() *cobra.Command {
var insecureHTTPAuth bool
var everywhere bool
var all bool
cmd := &cobra.Command{
Use:   "logout",
Short: "Log out of Entire",
RunE: func(cmd *cobra.Command, _ []string) error {
if err := requireSecureBaseURL(insecureHTTPAuth); err != nil {
return err
}
outW, errW := cmd.OutOrStdout(), cmd.ErrOrStderr()

// Pick the per-target revocation: just the current session, or
// every session on that context's core when --everywhere is set.
revokeForTarget := revokeCurrentSession
if everywhere {
revokeForTarget = revokeAllSessions
}

if all {
return runLogoutAll(cmd.Context(), outW, errW, auth.Contexts,
    auth.LoginTokenForContext, revokeForTarget, auth.RemoveContext,
    auth.NewContextStore(), api.AuthBaseURL(), insecureHTTPAuth)
}

// Revoke against the active context's core (matching what
// `auth status` lists), not a static AuthBaseURL.
target := resolveStatusTarget(auth.NewContextStore(), auth.Contexts, api.AuthBaseURL())

if err := runLogout(cmd.Context(), outW, errW,
    auth.NewContextStore(), revokeCurrent, revokeAll,
    auth.RemoveCurrentContext, api.AuthBaseURL(), everywhere); err != nil {
return err
}

fmt.Fprintln(outW, "Logged out.")
return nil
}
}

// runLogoutAll drains every saved login. For each context it revokes the
// session(s) on that context's own core (using its own bearer) and removes
the login locally, then clears the legacy keyring entry. Per-context
// failures warn but never abort the sweep — one stuck login can't strand the
// rest, and local removal always proceeds so the CLI ends fully logged out.