auth: remove `auth revoke`; redefine `logout --all` to revoke sessions · Entire
auth: remove auth revoke; redefine logout --all to revoke sessions
9ca4705→main·
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:
logoutrevokes the active session server-side (DELETE .../tokens/current) and removes the active context locally. (Unchanged default behaviour.)logout --alladditionally 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
// 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()