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:

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

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