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

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

14550a8→main·

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

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