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

`14550a8`→[main](/content/gh/entireio/cli/commits/main/index.html)·  
  
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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

## Sessions

51b6330bf003View transcript

## Changes

4

- cmd/entire/cli

- auth
  
    - Mcontext_store.go+35/-8

- Mcontexts_test.go+45

- Mlogout.go+98/-5

- Mlogout_test.go+172

```
49 unmodified lines

50
51
52
53
54
55
56
57
58
59
60
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94

49 unmodified lines

}); err != nil {
		return fmt.Errorf("remove current context: %w", err)
	}
	// Best-effort keychain cleanup, sequenced off the context just removed.
	// A missing entry is fine — the contexts.json removal above 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.
	if svc != "" && handle != "" {
		_ = 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
	}
	deleteContextKeychain(svc, handle)
	return nil
}

// 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

304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354

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)
	}
}

func TestSetCurrentContext(t *testing.T) {
	cfgDir := t.TempDir()
	...
}

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

```

7 unmodified lines

8
9
10
11
12
13
14
20 unmodified lines

35
36
37
38
39
40
41
1 unmodified line

43
44
45
44
45
46
47
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
8 unmodified lines

84
85
86
66
87
88
89
4 unmodified lines

94
95
96
97
98
99
100
80 unmodified lines

181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255

7 unmodified lines

"testing"

"github.com/entireio/cli/cmd/entire/cli/api"
	"github.com/entireio/cli/internal/entireclient/contexts"
}

const testLogoutToken = "tok123"

245 unmodified lines

t.Fatal("logout command not registered on root")
	}
}

// makeLogoutContexts builds a contextsProvider returning the given contexts
// with no active marker — `logout --all` ignores which one is current.
func makeLogoutContexts(cs ...*contexts.Context) contextsProvider {
	return func() ([]*contexts.Context, string, error) { return cs, "", nil }
}

func TestRunLogoutAll_RevokesAndRemovesEachContext(t *testing.T) {
	t.Parallel()

provider := makeLogoutContexts(
		&contexts.Context{Name: "eu", CoreURL: "https://eu.auth.entire.io"},
		&contexts.Context{Name: "us", CoreURL: "https://us.auth.entire.io"},
	)
	tokens := map[string]string{"eu": "tok-eu", "us": "tok-us"}
	tokenFor := func(c *contexts.Context) (string, error) { return tokens[c.Name], nil }

revoked := map[string]string{} // coreURL -> token
	revoke := func(_ context.Context, coreURL, token string) error {
		revoked[coreURL] = token
		return nil
	}
	removed := map[string]bool{}
	remove := func(name string) error { removed[name] = true; return nil }

store := newMockTokenStore()
	var out, errOut bytes.Buffer
	if err := runLogoutAll(context.Background(), &out, &errOut, provider, tokenFor, revoke, remove, store, "https://entire.io", false); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

if revoked["https://eu.auth.entire.io"] != "tok-eu" || revoked["https://us.auth.entire.io"] != "tok-us" {
		t.Fatalf("each context's session should be revoked against its own core+token, got %v", revoked)
	}
	if !removed["eu"] || !removed["us"] {
		t.Fatalf("both contexts should be removed locally, got %v", removed)
	}
	if !store.deleted["https://entire.io"] {
		t.Error("legacy keyring entry should be cleared on logout --all")
	}
	if !strings.Contains(out.String(), "Logged out of 2 saved login(s).") {
		t.Fatalf("stdout = %q, want count of 2", out.String())
	}
	if errOut.Len() != 0 {
		t.Fatalf("stderr = %q, want empty", errOut.String())
	}
}

func TestRunLogoutAll_NoContexts(t *testing.T) {
	t.Parallel()

revoke := func(context.Context, string, string) error {
		t.Fatal("revoke should not run with no contexts")
		return nil
	}
	remove := func(string) error { t.Fatal("remove should not run with no contexts"); return nil }
	store := newMockTokenStore()

var out, errOut bytes.Buffer
	if err := runLogoutAll(context.Background(), &out, &errOut, makeLogoutContexts(), nil, revoke, remove, store, "https://entire.io", false); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !strings.Contains(out.String(), "No saved logins to remove.") {
		t.Fatalf("stdout = %q, want the empty-state message", out.String())
	}
	if !store.deleted["https://entire.io"] {
		t.Error("legacy keyring entry should still be cleared even with no contexts")
	}
}

func TestRunLogoutAll_RevokeFailureWarnsButContinues(t *testing.T) {
	t.Parallel()

provider := makeLogoutContexts(
		&contexts.Context{Name: "eu", CoreURL: "https://eu.auth.entire.io"},
		&contexts.Context{Name: "us", CoreURL: "https://us.auth.entire.io"},
	)
	tokenFor := func(*contexts.Context) (string, error) { return testLogoutToken, nil }
	revoke := func(_ context.Context, coreURL, _ string) error {
		if coreURL == "https://eu.auth.entire.io" {
			return errors.New("connection refused")
		}
		return nil
	}
	removed := map[string]bool{}
	remove := func(name string) error { removed[name] = true; return nil }

var out, errOut bytes.Buffer
	if err := runLogoutAll(context.Background(), &out, &errOut, provider, tokenFor, revoke, remove, newMockTokenStore(), "https://entire.io", false); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !removed["eu"] || !removed["us"] {
		t.Fatalf("a server revoke failure must not strand local removal, got %v", removed)
	}
	if !strings.Contains(errOut.String(), `revocation failed for "eu"`) || !strings.Contains(errOut.String(), "connection refused") {
		t.Fatalf("stderr = %q, want a warning naming the failed context", errOut.String())
	}
	if !strings.Contains(out.String(), "Logged out of 2 saved login(s).") {
		t.Fatalf("stdout = %q, want count of 2 despite the warning", out.String())
	}
}

func TestRunLogoutAll_UnauthorizedRevokeIsSilent(t *testing.T) {
	t.Parallel()

provider := makeLogoutContexts(&contexts.Context{Name: "eu", CoreURL: "https://eu.auth.entire.io"})
	tokenFor := func(*contexts.Context) (string, error) { return testLogoutToken, nil }
	revoke := func(context.Context, string, string) error {
		return &api.HTTPError{StatusCode: http.StatusUnauthorized, Message: "Not authenticated"}
	}
	remove := func(string) error { return nil }

var out, errOut bytes.Buffer
	if err := runLogoutAll(context.Background(), &out, &errOut, provider, tokenFor, revoke, remove, newMockTokenStore(), "https://entire.io", false); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if errOut.Len() != 0 {
		t.Fatalf("stderr = %q, want empty: an already-invalid token is the desired state", errOut.String())
	}
}

func TestRunLogoutAll_UnreadableTokenRemovesLocallyOnly(t *testing.T) {
	t.Parallel()

provider := makeLogoutContexts(&contexts.Context{Name: "eu", CoreURL: "https://eu.auth.entire.io"})
	tokenFor := func(*contexts.Context) (string, error) { return "", errors.New("keyring locked") }
	revokeCalled := false
	revoke := func(context.Context, string, string) error { revokeCalled = true; return nil }
	removed := false
	remove := func(string) error { removed = true; return nil }

var out, errOut bytes.Buffer
	if err := runLogoutAll(context.Background(), &out, &errOut, provider, tokenFor, revoke, remove, newMockTokenStore(), "https://entire.io", false); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if revokeCalled {
		t.Error("revoke should be skipped when the token can't be read")
	}
	if !removed {
		t.Error("context should still be removed locally")
	}
	if !strings.Contains(errOut.String(), "removing locally only") {
		t.Fatalf("stderr = %q, want the locally-only warning", errOut.String())
	}
}

func TestRunLogoutAll_InsecureCoreSkipsRevoke(t *testing.T) {
	t.Parallel()

provider := makeLogoutContexts(&contexts.Context{Name: "local", CoreURL: "http://insecure.example.com"})
	tokenFor := func(*contexts.Context) (string, error) { return testLogoutToken, nil }
	revokeCalled := false
	revoke := func(context.Context, string, string) error { revokeCalled = true; return nil }
	removed := false
	remove := func(string) error { removed = true; return nil }

var out, errOut bytes.Buffer
	// insecureHTTPAuth=false: a plain-http core must not receive the bearer.
	if err := runLogoutAll(context.Background(), &out, &errOut, provider, tokenFor, revoke, remove, newMockTokenStore(), "https://entire.io", false); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if revokeCalled {
		t.Error("revoke should be skipped for a non-TLS core without --insecure-http-auth")
	}
	if !removed {
		t.Error("context should still be removed locally")
	}
	if !strings.Contains(errOut.String(), "skipping server-side revocation") {
		t.Fatalf("stderr = %q, want the insecure-skip warning", errOut.String())
	}
}
