# auth: remove `auth revoke`; redefine `logout --all` to revoke sessions

`9ca4705`→[main](/content/gh/entireio/cli/commits/main/index.html)·

toothbrush·1mo ago·5 files·+126 added/-282 removed

Delete the `entire auth revoke` command. Session management collapses to two verbs: `auth status` shows active sessions, `logout` ends them.

Redefine the `logout --all` flag — it no longer removes all *local* contexts. Instead:

- `logout` revokes the active session server-side (DELETE .../tokens/current) and removes the active context locally. (Unchanged default behaviour.)
- `logout --all` additionally asks the server to revoke *every* session on the active core (list families -> delete each by id). The local side is identical to the default.

After a logout clears the active context, the next saved context is promoted to active, so running `entire logout` repeatedly drains every saved login in turn.

Cross-core revoke is out of scope: these endpoints target AuthBaseURL's core only, pending the COR-389 control-plane retargeting.

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

## Sessions

9dc3b1c4312cView transcript

## Changes

5

- cmd/entire/cli

- Mauth.go+2/-97
  
  - Mauth_context_test.go+26/-9
  
  - Mauth_test.go+1/-141
  
  - Mlogout.go+59/-28
  
  - Mlogout_test.go+38/-7

```  
26 unmodified lines  
27  
28  
29  
30  
31  
32  
33  
30  
31  
32  
106 unmodified lines  
139  
140  
141  
146  
147  
142  
143  
144  
145  
146  
2 unmodified lines  
149  
150  
151  
156  
152  
153  
154  
275 unmodified lines  
430  
431  
432  
438  
439  
440  
441  
442  
443  
444  
445  
446  
447  
448  
449  
450  
451  
452  
453  
454  
455  
456  
457  
458  
459  
460  
461  
462  
463  
464  
465  
466  
467  
468  
469  
470  
471  
472  
473  
474  
475  
476  
477  
478  
479  
480  
481  
482  
483  
484  
485  
486  
487  
488  
489  
490  
491  
492  
493  
494  
495  
496  
497  
498  
499  
500  
501  
502  
503  
504  
505  
506  
507  
508  
509  
510  
511  
512  
513  
514  
515  
516  
517  
518  
519  
520  
521  
522  
523  
524  
525  
526  
527
```

```go
// wrong-audience keyring token.
type authTokenLister func(ctx context.Context) ([]api.Token, error)

// authTokenRevoker revokes a single API token by id. Same bearer-
// resolution contract as authTokenLister.
type authTokenRevoker func(ctx context.Context, id string) error

// User-visible placeholder strings. Promoted to constants so tests and
// production share a single source of truth.
const (
106 unmodified lines

func newAuthCmd() *cobra.Command {
	cmd := &cobra.Command{
		Use:   "auth",
		Short: "Manage authentication and API tokens",
		Long:  "Authentication subcommands. Includes login, logout, status, listing tokens, and revoking tokens.",
		Short: "Manage authentication",
		Long:  "Authentication subcommands. Includes login, logout, status, and login-context management (contexts, use).",
		RunE: func(cmd *cobra.Command, _ []string) error {
			return cmd.Help()
		},
		
		cmd.AddCommand(newLoginCmd())
		cmd.AddCommand(newLogoutCmd())
		cmd.AddCommand(newAuthStatusCmd())
		cmd.AddCommand(newAuthRevokeCmd())
		cmd.AddCommand(newAuthContextsCmd())
		cmd.AddCommand(newAuthUseCmd())
		return cmd
	}

// --- revoke -----------------------------------------------------------------

func newAuthRevokeCmd() *cobra.Command {
	var revokeCurrent bool
	var insecureHTTPAuth bool
	cmd := &cobra.Command{
		Use:   "revoke [id]",
		Short: "Revoke an API token by id",
		Long:  "Revoke a specific API token. Use --current to revoke the token used by this CLI (equivalent to 'entire logout').",
		Args:  cobra.MaximumNArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			id := ""
			if len(args) == 1 {
				id = args[0]
			}
			if id == "" && !revokeCurrent {
				return cmd.Help()
			}
			if id != "" && revokeCurrent {
				return errors.New("cannot use both <id> and --current")
			}
			if err := requireSecureBaseURL(insecureHTTPAuth); err != nil {
				return err
			}
			return runAuthRevoke(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(),
				auth.NewContextStore(), defaultListTokens, defaultRevokeTokenByID, defaultRevokeCurrentToken,
				auth.RemoveCurrentContext, api.AuthBaseURL(), id, revokeCurrent)
		},
	}
	cmd.Flags().BoolVar(&revokeCurrent, "current", false, "Revoke the token used by this CLI and remove the local copy")
	addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth)
	return cmd
}

func defaultRevokeTokenByID(ctx context.Context, id string) error {
	token, err := resolveDataAPIToken(ctx)
	if err != nil {
		return err
	}
	return newAPITokensClient(token).RevokeToken(ctx, id) //nolint:wrapcheck // RevokeToken already wraps with action context
}

func runAuthRevoke(
	ctx context.Context,
	outW, errW io.Writer,
	store tokenStore,
	list authTokenLister,
	revokeByID authTokenRevoker,
	revokeCurrent revokeCurrentFunc,
	clearContext clearContextFunc,
	baseURL, id string,
	current bool,
) error {
	token, err := store.GetToken(baseURL)
	if err != nil {
		return fmt.Errorf("read keychain: %w", err)
	}
	if token == "" {
		return fmt.Errorf("not logged in to %s; run 'entire login' first", baseURL)
	}

if current {
		// Revoking our own token is just logout — reuse that path so behavior
		// stays identical (best-effort revoke + local delete + context clear).
		return runLogout(ctx, outW, errW, store, revokeCurrent, clearContext, baseURL)
	}

if err := revokeByID(ctx, id); err != nil {
		return err
	}

// The list endpoint requires bearer auth, so a 401 here means the id we
	// just revoked was the same one this CLI is using — the local copy is now
		// stale and would otherwise produce confusing 401s on every command, so
		// remove both the legacy keyring entry and the active context.
	if _, listErr := list(ctx); listErr != nil && api.IsHTTPErrorStatus(listErr, http.StatusUnauthorized) {
		if delErr := store.DeleteToken(baseURL); delErr != nil {
			return fmt.Errorf("revoked token %s but failed to remove local copy: %w", id, delErr)
		}
		if ctxErr := clearContext(); ctxErr != nil {
			fmt.Fprintf(errW, "Warning: revoked token %s but failed to clear current context: %v\n", id, ctxErr)
		}
		fmt.Fprintf(outW, "Revoked token %s (this was your local token; removed from keychain).\n", id)
		return nil
	}

fmt.Fprintf(outW, "Revoked token %s.\n", id)
	return nil
}
```

Mcmd/entire/cli/auth.go+2/-97

```  
86 unmodified lines

87
88
89
90
90
91
92
93
1 unmodified line

95
96
97
98
98
99
100
100
101
102
103
103
105
106
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
109
110
111
123
124
125
126
127
128
129
130

86 unmodified lines

}
}

func TestNoteRemainingLogins(t *testing.T) {
func TestPromoteNextLogin(t *testing.T) {
cfgDir := t.TempDir()
t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
1 unmodified line

// No contexts: silent.
var empty bytes.Buffer
noteRemainingLogins(&empty)
promoteNextLogin(&empty, &empty)
if empty.Len() != 0 {
	t.Fatalf("no remaining contexts should be silent, got %q", empty.String())
		t.Fatalf("no contexts should be silent, got %q", empty.String())
}

// A surviving context: names it and points at --all.
exp := time.Now().Add(time.Hour).Unix()
if _, err := auth.RecordLoginContext(makeContextJWT(t, fmt.Sprintf(`{"iss":"https://core.example.com","handle":"alice","exp":%d}`, exp)), "", true); err != nil {
	t.Fatalf("record: %v", err)
if _, err := auth.RecordLoginContext(makeContextJWT(t, fmt.Sprintf(`{"iss":"https://a.example.com","handle":"alice","exp":%d}`, exp)), "", true); err != nil {
	t.Fatalf("record a: %v", err)
}
if _, err := auth.RecordLoginContext(makeContextJWT(t, fmt.Sprintf(`{"iss":"https://b.example.com","handle":"bob","exp":%d}`, exp)), "", true); err != nil {
	t.Fatalf("record b: %v", err)
}

// A current context is set: promotion is a no-op (nothing to promote into).
var noop bytes.Buffer
promoteNextLogin(&noop, &noop)
if noop.Len() != 0 {
	t.Fatalf("with a current context set, promote should be silent, got %q", noop.String())
}

// Clear the active context (as logout does): the remaining login is promoted.
if err := auth.RemoveCurrentContext(); err != nil {
	t.Fatalf("remove current: %v", err)
}
var buf bytes.Buffer
noteRemainingLogins(&buf)
if !strings.Contains(buf.String(), "core.example.com") || !strings.Contains(buf.String(), "--all") {
	t.Fatalf("expected note naming the context and --all, got %q", buf.String())
promoteNextLogin(&buf, &buf)
if !strings.Contains(buf.String(), "Now using") {
	t.Fatalf("expected promotion message, got %q", buf.String())
}
if _, current, err := auth.Contexts(); err != nil || current == "" {
	t.Fatalf("expected a context to be promoted to current (current=%q, err=%v)", current, err)
}
}
```

Mcmd/entire/cli/auth_context_test.go+26/-9

```  
19 unmodified lines

20
21
22
23
23
24
25
314 unmodified lines

340
341
342
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
```

Mcmd/entire/cli/auth_test.go+1/-141

```  
4 unmodified lines

5
6
7
8
8
9
10
27 unmodified lines

38
39
40
42
43
44
41
42
43
44
45
46
47
48
49
50
51
52
50
51
52
53
53
55
54
55
56
57
58
59
60
58
59
60
61
64
62
63
64
65
66
69
70
71
72
73
74
67
68
69
70
71
72
73
74
75
76
77
78
79
77
78
79
80
81
81
82
83
84
82
83
84
85
4 unmodified lines

90
91
92
95
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
1 unmodified line

127
128
129
130
131
132
133
134
135
136
137
107
138
139
140
141
```

4 unmodified lines

"fmt"
	"io"
	"net/http"
	"strings"

"github.com/entireio/cli/cmd/entire/cli/api"
	"github.com/entireio/cli/cmd/entire/cli/auth"
27 unmodified lines

Use:   "logout",
		Short: "Log out of Entire",
		Long: "Log out of Entire.\n\n" +
		"By default this removes the active login only. Other saved logins (contexts)\n" +
		"remain and can still authenticate `git clone entire://…` against any cluster\n" +
		"fronted by their login server. Use --all to remove every saved login.",
		"By default this ends the active session only (server-side) and removes the\n" +
		"active login from this machine. Other saved logins (contexts) remain and can\n" +
		"still authenticate `git clone entire://…` against clusters fronted by their\n" +
		"login server. Pass --all to additionally revoke every session on the active\n" +
		"core server-side.\n\n" +
		"After logging out, the next saved login (if any) becomes active, so running\n" +
		"`entire logout` repeatedly drains every saved login in turn.",
		RunE: func(cmd *cobra.Command, _ []string) error {
			if err := requireSecureBaseURL(insecureHTTPAuth); err != nil {
				return err
			}
			outW, errW := cmd.OutOrStdout(), cmd.ErrOrStderr()
			clearFn := auth.RemoveCurrentContext
			if all {
				clearFn = func() error { _, err := auth.RemoveAllContexts(); return err } //nolint:wrapcheck // RemoveAllContexts already returns a contextual error
			}
			if err := runLogout(cmd.Context(), outW, errW,
				auth.NewContextStore(), defaultRevokeCurrentToken, clearFn, api.AuthBaseURL()); err != nil {
				auth.NewContextStore(), defaultRevokeCurrentToken, defaultRevokeAllSessions,
				auth.RemoveCurrentContext, api.AuthBaseURL(), all); err != nil {
				return err
			}
			if !all {
				noteRemainingLogins(errW)
			}
			promoteNextLogin(outW, errW)
			return nil
		},
	}
	cmd.Flags().BoolVar(&all, "all", false, "Remove all saved logins (contexts), not just the active one")
	cmd.Flags().BoolVar(&all, "all", false, "Also revoke every session on the active core server-side, not just the active one")
	addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth)
	return cmd
}

// noteRemainingLogins warns when other saved contexts survive a default
// logout — they can still authenticate clone/push against clusters bound to
// them, so "Logged out." alone would overstate the result.
func noteRemainingLogins(errW io.Writer) {
	all, _, err := auth.Contexts()
	if err != nil || len(all) == 0 {
// promoteNextLogin makes the first remaining saved context active after a
// logout cleared the previous one. This is what lets `entire logout` drain
// every login when run repeatedly: each call ends the active login and
// promotes the next, until none remain. Best-effort and informational —
// logout already succeeded by the time we get here.
func promoteNextLogin(outW, errW io.Writer) {
	all, current, err := auth.Contexts()
	if err != nil || current != "" || len(all) == 0 {
		return
	}
	names := make([]string, 0, len(all))
	for _, c := range all {
		names = append(names, c.Name)
	next := all[0].Name
	if err := auth.SetCurrentContext(next); err != nil {
		fmt.Fprintf(errW, "Note: %d saved login(s) remain; run `entire auth use <context>` to switch.\n", len(all))
		return
	}
	fmt.Fprintf(errW,
		"Note: %d other saved login(s) remain and can still authenticate clones: %s\n"+
		"Run `entire logout --all` to remove them, or `entire auth use <context>` to switch.\n",
		len(all), strings.Join(names, ", "))
	fmt.Fprintf(outW, "Now using %q (%d saved login(s) remain; run `entire logout` again to remove each).\n", next, len(all))
}

func defaultRevokeCurrentToken(ctx context.Context) error {
4 unmodified lines

return newAPITokensClient(token).RevokeCurrentToken(ctx) //nolint:wrapcheck // RevokeCurrentToken already wraps with action context
}

func runLogout(ctx context.Context, outW, errW io.Writer, store tokenStore, revoke revokeCurrentFunc, clearContext clearContextFunc, baseURL string) error {
// defaultRevokeAllSessions revokes every active login session on the active
// core (the `entire logout --all` path). It resolves a data-API bearer once,
// lists the user's sessions, and deletes each by id. Best-effort across
// sessions: it attempts them all and returns the first failure, so one stuck
// session doesn't strand the rest. Cross-core revoke is out of scope — these
// endpoints target api.AuthBaseURL()'s core only.
func defaultRevokeAllSessions(ctx context.Context) error {
	token, err := resolveDataAPIToken(ctx)
	if err != nil {
		return err
	}
	client := newAPITokensClient(token)
	sessions, err := client.ListTokens(ctx)
	if err != nil {
		return fmt.Errorf("list sessions: %w", err)
	}
	var firstErr error
	for _, s := range sessions {
		if err := client.RevokeToken(ctx, s.ID); err != nil && firstErr == nil {
			firstErr = fmt.Errorf("revoke session %s: %w", s.ID, err)
		}
	}
	return firstErr
}

// runLogout ends the user's login. revokeCurrent revokes just the active
// session; revokeAll (used when all is set) revokes every session on the
// active core. Either way the local keyring entry and active context are
// removed, so the CLI reports logged-out even if the server call fails.
func runLogout(ctx context.Context, outW, errW io.Writer, store tokenStore, revokeCurrent, revokeAll revokeCurrentFunc, clearContext clearContextFunc, baseURL string, all bool) error {
	token, err := store.GetToken(baseURL)
	if err != nil {
		// Fall through to the local delete: we still want the keyring entry
		fmt.Fprintf(errW, "Warning: failed to read token before revocation: %v\n", err)
	}
	if token != "" {
		revoke := revokeCurrent
		if all {
			revoke = revokeAll
		}
		if err := revoke(ctx); err != nil && !api.IsHTTPErrorStatus(err, http.StatusUnauthorized) {
			// Best-effort: a transient network error shouldn't block local
			// logout. A 401 means the token is already invalid server-side,
			// so the desired state is achieved — no warning needed.
			fmt.Fprintf(errW, "Warning: server-side token revocation failed: %v\n", err)
			fmt.Fprintf(errW, "Warning: server-side session revocation failed: %v\n", err)
		}
	}
}
```

Mcmd/entire/cli/logout.go+59/-28

```  
58 unmodified lines

59
60
61
62
62
63
64
65
24 unmodified lines

90
91
92
93
93
94
95
96
20 unmodified lines

117
118
119
120
120
121
122
123
1 unmodified line

125
126
127
128
128
129
130
131
15 unmodified lines

147
148
149
150
150
151
152
153
22 unmodified lines

176
177
178
179
179
180
181
182
19 unmodified lines

202
203
204
205
205
206
207
208
5 unmodified lines

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

58 unmodified lines

}

var out, errOut bytes.Buffer
	err := runLogout(context.Background(), &out, &errOut, store, revoke, func() error { return nil }, "https://entire.io")
	err := runLogout(context.Background(), &out, &errOut, store, revoke, func(context.Context) error { return nil }, func() error { return nil }, "https://entire.io", false)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
24 unmodified lines

}

var out, errOut bytes.Buffer
	err := runLogout(context.Background(), &out, &errOut, store, revoke, func() error { return nil }, "https://entire.io")
	err := runLogout(context.Background(), &out, &errOut, store, revoke, func(context.Context) error { return nil }, func() error { return nil }, "https://entire.io", false)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
20 unmodified lines

}

var out, errOut bytes.Buffer
	err := runLogout(context.Background(), &out, &errOut, store, revoke, func() error { return nil }, "https://entire.io")
	err := runLogout(context.Background(), &out, &errOut, store, revoke, func(context.Context) error { return nil }, func() error { return nil }, "https://entire.io", false)
	if err == nil {
		t.Fatal("expected error, got nil")
	}
5 unmodified lines

}
}

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

store := newMockTokenStore()
store.tokens["https://entire.io"] = testLogoutToken

currentCalled, allCalled := false, false
revokeCurrent := func(context.Context) error { currentCalled = true; return nil }
revokeAll := func(context.Context) error { allCalled = true; return nil }

var out, errOut bytes.Buffer
err := runLogout(context.Background(), &out, &errOut, store,
	revokeCurrent, revokeAll, func() error { return nil }, "https://entire.io", true)
if err != nil {
	t.Fatalf("unexpected error: %v", err)
}

if currentCalled {
		t.Error("--all should not call the current-session revoke")
}
if !allCalled {
		t.Error("--all should call the revoke-all path")
}
if !store.deleted["https://entire.io"] {
	t.Fatal("local token should still be deleted under --all")
}
if !strings.Contains(out.String(), "Logged out.") {
	t.Fatalf("stdout = %q, want to contain %q", out.String(), "Logged out.")
}
}

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

```
