From e9f21c2ff945ddaa7987dd56e284f368c9cc4f82 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Thu, 14 May 2026 12:16:22 +0100 Subject: [PATCH 1/2] feat(cli): add optional server-side auth logout revocation Introduce cq auth logout --revoke and --all-devices so users can request backend session invalidation while preserving local credentials on non-recoverable revoke failures. --- cli/README.md | 15 +++++- cli/cmd/auth.go | 61 +++++++++++++++++---- cli/cmd/auth_test.go | 67 +++++++++++++++++++++++ cli/internal/auth/doc.go | 3 +- cli/internal/auth/errors.go | 4 ++ cli/internal/auth/http_client.go | 31 ++++++++++- cli/internal/auth/http_client_test.go | 65 +++++++++++++++++++++- cli/internal/auth/logout.go | 71 ++++++++++++++++++++---- cli/internal/auth/logout_test.go | 78 +++++++++++++++++++++++++++ cli/internal/auth/onboarding_test.go | 9 ++++ cli/internal/auth/platform_client.go | 7 +++ 11 files changed, 387 insertions(+), 24 deletions(-) diff --git a/cli/README.md b/cli/README.md index a964aa4..93741af 100644 --- a/cli/README.md +++ b/cli/README.md @@ -81,9 +81,22 @@ cq auth status # Clear locally-stored credentials. cq auth logout + +# Revoke server session first, then clear local credentials. +cq auth logout --revoke + +# Revoke all server sessions/devices, then clear local credentials. +cq auth logout --revoke --all-devices ``` -`cq auth` requires `CQ_ADDR` (or `--addr`) to point at a cq-compatible platform. `logout` is local-only; server-side session revocation is tracked separately and will land under a future `--revoke` flag once the platform exposes the necessary endpoint. +`cq auth` requires `CQ_ADDR` (or `--addr`) for networked commands. + +`cq auth logout` behavior: +- default: local-only credential cleanup +- `--revoke`: call `/auth/logout` before local cleanup +- `--revoke --all-devices`: request logout across all devices + +If server revocation fails (other than an already-invalid session), local credentials are kept so you can retry. ### Authentication vs API keys diff --git a/cli/cmd/auth.go b/cli/cmd/auth.go index fb8aa2a..0e0bdea 100644 --- a/cli/cmd/auth.go +++ b/cli/cmd/auth.go @@ -36,10 +36,14 @@ Notes: // authLogoutLongDoc is the help text shown for "cq auth logout". var authLogoutLongDoc = `Clear locally-stored sign-in credentials. -logout is currently local-only: it removes the session JWT and cached -identity from the credential store but does not invalidate the session -on the server side. Server-side revocation will land under a future ---revoke flag once the platform exposes the necessary endpoint.` +By default, this command is local-only: it removes the session JWT and +cached identity from the credential store. + +When configured, logout can also request server-side session revocation +before local credentials are cleared. + +If server revocation fails for reasons other than an already-invalid +session, local credentials are left intact so you can retry.` // authLongDoc is the help text shown for the "cq auth" parent command. var authLongDoc = fmt.Sprintf(`Manage interactive sign-in for the cq platform. @@ -176,23 +180,60 @@ func newAuthLoginCmd(cfg authOptions) *cobra.Command { // newAuthLogoutCmd returns the "cq auth logout" subcommand. func newAuthLogoutCmd(cfg authOptions) *cobra.Command { - return &cobra.Command{ - Use: "logout", + var ( + revoke bool + allDevices bool + ) + + cmd := &cobra.Command{ + Use: "logout [--revoke] [--all-devices]", Short: "Clear locally-stored sign-in credentials.", Long: authLogoutLongDoc, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + if allDevices && !revoke { + return errors.New("--all-devices requires --revoke") + } + + var client auth.Client + if revoke { + addr, err := requireAuthAddr() + if err != nil { + return err + } + + client = cfg.newClient(addr) + } + store, err := cfg.newStore() if err != nil { return fmt.Errorf("opening credential store: %w", err) } return auth.Logout(cmd.Context(), auth.LogoutConfig{ - Store: store, - Out: cmd.OutOrStdout(), + Store: store, + Client: client, + Revoke: revoke, + AllDevices: allDevices, + Out: cmd.OutOrStdout(), }) }, } + + cmd.Flags().BoolVar( + &revoke, + "revoke", + false, + "Request server-side session revocation before local cleanup.", + ) + cmd.Flags().BoolVar( + &allDevices, + "all-devices", + false, + "Request server-side revocation across all devices/sessions (requires --revoke).", + ) + + return cmd } // newAuthProvidersCmd returns the "cq auth providers" subcommand: a @@ -267,8 +308,8 @@ func newAuthStatusCmd(cfg authOptions) *cobra.Command { } // requireAuthAddr returns the effective platform address from --addr -// or the environment, erroring if neither is configured. Login and -// providers need a real URL; logout is purely local and skips this. +// or the environment, erroring if neither is configured. +// Subcommands: login, providers, and logout --revoke need a real URL. func requireAuthAddr() (string, error) { if flagAddr == "" { return "", fmt.Errorf("no platform address configured. Set %s or pass --addr", envVarAddr) diff --git a/cli/cmd/auth_test.go b/cli/cmd/auth_test.go index d10a56e..4de6a57 100644 --- a/cli/cmd/auth_test.go +++ b/cli/cmd/auth_test.go @@ -58,6 +58,7 @@ type stubAuthClient struct { oauthProviders func(ctx context.Context) ([]auth.Provider, error) createAPIKey func(ctx context.Context, jwt string, req auth.CreateAPIKeyRequest) (auth.CreatedAPIKey, error) listAPIKeys func(ctx context.Context, jwt string) ([]auth.APIKey, error) + logout func(ctx context.Context, jwt string, allDevices bool) error revokeAPIKey func(ctx context.Context, jwt string, keyID string) error } @@ -101,6 +102,14 @@ func (s *stubAuthClient) ListAPIKeys(ctx context.Context, jwt string) ([]auth.AP return s.listAPIKeys(ctx, jwt) } +func (s *stubAuthClient) Logout(ctx context.Context, jwt string, allDevices bool) error { + if s.logout == nil { + panic("Logout not stubbed") + } + + return s.logout(ctx, jwt, allDevices) +} + func (s *stubAuthClient) RevokeAPIKey(ctx context.Context, jwt string, keyID string) error { if s.revokeAPIKey == nil { // pragma: allowlist secret panic("RevokeAPIKey not stubbed") @@ -168,6 +177,64 @@ func TestAuthLogout_ClearsStoredCredentials(t *testing.T) { require.ErrorIs(t, err, credstore.ErrNotFound) } +func TestAuthLogout_AllDevicesRequiresRevoke(t *testing.T) { + testSetup(t) + + cmd := NewAuthCmd(stubStore(&memStore{})) + cmd.SetArgs([]string{"logout", "--all-devices"}) + + err := cmd.ExecuteContext(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "--all-devices requires --revoke") +} + +func TestAuthLogout_RevokeRequiresAddr(t *testing.T) { + testSetup(t) + + cmd := NewAuthCmd(stubStore(&memStore{}), stubClient(&stubAuthClient{ + logout: func(context.Context, string, bool) error { + return nil + }, + })) + cmd.SetArgs([]string{"logout", "--revoke"}) + + err := cmd.ExecuteContext(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), envVarAddr) +} + +func TestAuthLogout_RevokeAllDevices_RevokesThenClearsStoredCredentials(t *testing.T) { + testSetup(t) + setFlag(t, &flagAddr, "https://platform.example.com") + + store := &memStore{} + require.NoError(t, store.Save(credstore.Credentials{SessionJWT: "j", Username: "alice"})) + + called := false + client := &stubAuthClient{ + logout: func(_ context.Context, jwt string, allDevices bool) error { + called = true + require.Equal(t, "j", jwt) + require.True(t, allDevices) + + return nil + }, + } + + cmd := NewAuthCmd(stubStore(store), stubClient(client)) + cmd.SetArgs([]string{"logout", "--revoke", "--all-devices"}) + + var out bytes.Buffer + cmd.SetOut(&out) + + require.NoError(t, cmd.ExecuteContext(context.Background())) + require.True(t, called) + require.Contains(t, out.String(), "all devices") + + _, err := store.Load() + require.ErrorIs(t, err, credstore.ErrNotFound) +} + func TestAuthStatus_NotSignedInReturnsErrNotFound(t *testing.T) { testSetup(t) diff --git a/cli/internal/auth/doc.go b/cli/internal/auth/doc.go index 2a59549..01d528a 100644 --- a/cli/internal/auth/doc.go +++ b/cli/internal/auth/doc.go @@ -1,6 +1,7 @@ // Package auth implements the interactive sign-in flow used by // "cq auth login", the local-state inspection used by "cq auth status", -// and the local-credential cleanup used by "cq auth logout". +// and logout, which can clear local credentials only or also request +// server-side revocation when the platform supports it. // // The package wires together five concerns: // diff --git a/cli/internal/auth/errors.go b/cli/internal/auth/errors.go index bd225f0..4cd1081 100644 --- a/cli/internal/auth/errors.go +++ b/cli/internal/auth/errors.go @@ -19,6 +19,10 @@ var ErrInvalidGrant = errors.New("auth: invalid grant") // "run cq auth providers" hint. var ErrProviderRequired = errors.New("auth: provider required") +// ErrLogoutUnsupported is returned by Logout when the configured +// platform does not expose the logout endpoint. +var ErrLogoutUnsupported = errors.New("auth: server-side logout unsupported") + // ErrSessionExpired is returned by JWT-bearing methods when the // platform refuses the supplied token (HTTP 401). The CLI surfaces this // to users as a hint to re-run cq auth login. The error deliberately diff --git a/cli/internal/auth/http_client.go b/cli/internal/auth/http_client.go index 41ff695..b7860ef 100644 --- a/cli/internal/auth/http_client.go +++ b/cli/internal/auth/http_client.go @@ -172,6 +172,34 @@ func (c *httpClient) OAuthProviders(ctx context.Context) ([]Provider, error) { return resp.Providers, nil } +// Logout implements Client. +func (c *httpClient) Logout(ctx context.Context, jwt string, allDevices bool) error { + path := apiVersionPrefix + "/auth/logout" + if allDevices { + path += "?all_devices=true" + } + + req, err := c.newRequest(ctx, http.MethodPost, path, nil) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+jwt) + + if err := c.send(req, nil); err != nil { + if status, ok := errors.AsType[*PlatformStatusError](err); ok { + switch status.StatusCode { + case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented: + return ErrLogoutUnsupported + } + } + + return err + } + + return nil +} + // RevokeAPIKey implements Client. func (c *httpClient) RevokeAPIKey(ctx context.Context, jwt string, keyID string) error { path := apiVersionPrefix + "/users/me/api-keys/" + url.PathEscape(keyID) + "/revoke" @@ -189,8 +217,7 @@ func (c *httpClient) RevokeAPIKey(ctx context.Context, jwt string, keyID string) // the caller because the same code can mean "user record gone" // on list/create; here, with a key ID in hand, we know exactly // what to surface. - var status *PlatformStatusError - if errors.As(err, &status) && status.StatusCode == http.StatusNotFound { + if status, ok := errors.AsType[*PlatformStatusError](err); ok && status.StatusCode == http.StatusNotFound { return &APIKeyNotFoundError{KeyID: keyID} } diff --git a/cli/internal/auth/http_client_test.go b/cli/internal/auth/http_client_test.go index a511b5e..6cad6b1 100644 --- a/cli/internal/auth/http_client_test.go +++ b/cli/internal/auth/http_client_test.go @@ -20,6 +20,7 @@ type captured struct { sync.Mutex method string path string + query string auth string body []byte } @@ -38,6 +39,7 @@ func (c *captured) recordRequest(r *http.Request) { c.method = r.Method c.path = r.URL.Path + c.query = r.URL.RawQuery c.auth = r.Header.Get("Authorization") c.body = body } @@ -48,7 +50,7 @@ func (c *captured) snapshot() captured { c.Lock() defer c.Unlock() - return captured{method: c.method, path: c.path, auth: c.auth, body: c.body} + return captured{method: c.method, path: c.path, query: c.query, auth: c.auth, body: c.body} } func TestClient_OAuthProviders_ReturnsEnabledProviders(t *testing.T) { @@ -362,3 +364,64 @@ func TestClient_ClaimUsername_OtherErrorReturnsGenericError(t *testing.T) { var unavail *UsernameUnavailableError require.False(t, errors.As(err, &unavail)) } + +func TestClient_Logout_PostsToAuthLogout(t *testing.T) { + cap := newCapture() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cap.recordRequest(r) + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL) + err := client.Logout(context.Background(), "jwt-token", false) + require.NoError(t, err) + + c := cap.snapshot() + require.Equal(t, http.MethodPost, c.method) + require.Equal(t, "/api/v1/auth/logout", c.path) + require.Equal(t, "Bearer jwt-token", c.auth) +} + +func TestClient_Logout_AllDevicesAddsQueryParam(t *testing.T) { + cap := newCapture() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cap.recordRequest(r) + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL) + err := client.Logout(context.Background(), "jwt-token", true) + require.NoError(t, err) + + c := cap.snapshot() + require.Equal(t, "/api/v1/auth/logout", c.path) + require.Equal(t, "all_devices=true", c.query) +} + +func TestClient_Logout_UnsupportedEndpointReturnsTypedError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"Not Found"}`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL) + err := client.Logout(context.Background(), "jwt-token", false) + require.ErrorIs(t, err, ErrLogoutUnsupported) +} + +func TestClient_Logout_ExpiredSessionReturnsErrSessionExpired(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"detail":"Invalid or expired token"}`)) + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL) + err := client.Logout(context.Background(), "jwt-token", false) + require.ErrorIs(t, err, ErrSessionExpired) +} diff --git a/cli/internal/auth/logout.go b/cli/internal/auth/logout.go index 99ccbb9..a83febd 100644 --- a/cli/internal/auth/logout.go +++ b/cli/internal/auth/logout.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "fmt" "io" @@ -13,27 +14,79 @@ type LogoutConfig struct { // Store is the credstore to clear. Store credstore.Store + // Client is the platform HTTP client. Required only when Revoke is + // true. + Client Client + + // Revoke asks the platform to invalidate the current session before + // local credentials are deleted. + Revoke bool + + // AllDevices requests server-side revocation across every device. + // Ignored unless Revoke is true. + AllDevices bool + // Out receives the user-facing acknowledgement. Defaults to // io.Discard when nil. Out io.Writer } -// Logout removes any locally-stored session credentials. +// Logout clears locally-stored session credentials. // -// Logout is currently local-only: server-side session revocation is -// tracked separately. A --revoke flag will be added once the platform -// exposes a logout endpoint. -func Logout(_ context.Context, p LogoutConfig) error { +// When Revoke is true, Logout first asks the platform to invalidate the +// current session, then clears local credentials. Non-401 revocation +// failures leave local credentials untouched so the user can retry. +func Logout(ctx context.Context, p LogoutConfig) error { out := p.Out if out == nil { out = io.Discard } - if err := p.Store.Delete(); err != nil { - return fmt.Errorf("clearing credentials: %w", err) + clearAndReport := func(msg string) error { + if err := p.Store.Delete(); err != nil { + return fmt.Errorf("clearing credentials: %w", err) + } + + _, _ = fmt.Fprintln(out, msg) + + return nil + } + + if !p.Revoke { + return clearAndReport("Signed out (local credentials cleared).") } - _, _ = fmt.Fprintln(out, "Signed out (local credentials cleared).") + if p.Client == nil { + return errors.New("auth: Logout with Revoke requires Client") + } + + creds, err := p.Store.Load() + if err != nil { + if errors.Is(err, credstore.ErrNotFound) { + _, _ = fmt.Fprintln(out, "Signed out (local credentials already absent).") + + return nil + } + + return fmt.Errorf("loading credentials: %w", err) + } + + if creds.SessionJWT == "" { + return clearAndReport("Signed out (local credentials cleared).") + } + + err = p.Client.Logout(ctx, creds.SessionJWT, p.AllDevices) + if err != nil { + if errors.Is(err, ErrSessionExpired) { + return clearAndReport("Signed out locally. Session was already expired or invalid, so server revocation could not be confirmed.") + } + + return fmt.Errorf("revoking server session: %w", err) + } + + if p.AllDevices { + return clearAndReport("Signed out (server session revoked on all devices; local credentials cleared).") + } - return nil + return clearAndReport("Signed out (server session revoked; local credentials cleared).") } diff --git a/cli/internal/auth/logout_test.go b/cli/internal/auth/logout_test.go index 8c1a92b..8b27ad9 100644 --- a/cli/internal/auth/logout_test.go +++ b/cli/internal/auth/logout_test.go @@ -3,6 +3,7 @@ package auth import ( "bytes" "context" + "errors" "testing" "github.com/stretchr/testify/require" @@ -30,3 +31,80 @@ func TestLogout_NotSignedInIsIdempotent(t *testing.T) { require.NoError(t, Logout(context.Background(), LogoutConfig{Store: store, Out: out})) require.Contains(t, out.String(), "Signed out") } + +func TestLogout_RevokeSuccess_ClearsCredentials(t *testing.T) { + store := newMemStore() + require.NoError(t, store.Save(credstore.Credentials{SessionJWT: "j", Username: "alice"})) + + called := false + client := &stubAuthClient{ + logout: func(_ context.Context, jwt string, allDevices bool) error { + called = true + require.Equal(t, "j", jwt) + require.True(t, allDevices) + + return nil + }, + } + + out := &bytes.Buffer{} + + require.NoError(t, Logout(context.Background(), LogoutConfig{ + Store: store, + Client: client, + Revoke: true, + AllDevices: true, + Out: out, + })) + require.True(t, called) + require.Contains(t, out.String(), "server session revoked on all devices") + + _, err := store.Load() + require.ErrorIs(t, err, credstore.ErrNotFound) +} + +func TestLogout_RevokeFailure_PreservesCredentials(t *testing.T) { + store := newMemStore() + require.NoError(t, store.Save(credstore.Credentials{SessionJWT: "j", Username: "alice"})) + + client := &stubAuthClient{ + logout: func(context.Context, string, bool) error { + return errors.New("upstream boom") + }, + } + + err := Logout(context.Background(), LogoutConfig{ + Store: store, + Client: client, + Revoke: true, + Out: &bytes.Buffer{}, + }) + require.Error(t, err) + + creds, loadErr := store.Load() + require.NoError(t, loadErr) + require.Equal(t, "j", creds.SessionJWT) +} + +func TestLogout_RevokeExpiredSession_ClearsCredentials(t *testing.T) { + store := newMemStore() + require.NoError(t, store.Save(credstore.Credentials{SessionJWT: "j", Username: "alice"})) + + client := &stubAuthClient{ + logout: func(context.Context, string, bool) error { + return ErrSessionExpired + }, + } + + out := &bytes.Buffer{} + require.NoError(t, Logout(context.Background(), LogoutConfig{ + Store: store, + Client: client, + Revoke: true, + Out: out, + })) + require.Contains(t, out.String(), "could not be confirmed") + + _, err := store.Load() + require.ErrorIs(t, err, credstore.ErrNotFound) +} diff --git a/cli/internal/auth/onboarding_test.go b/cli/internal/auth/onboarding_test.go index 09a9b49..76b05b8 100644 --- a/cli/internal/auth/onboarding_test.go +++ b/cli/internal/auth/onboarding_test.go @@ -24,6 +24,7 @@ type stubAuthClient struct { claimUsername func(ctx context.Context, jwt, username string) (User, error) createAPIKey func(ctx context.Context, jwt string, req CreateAPIKeyRequest) (CreatedAPIKey, error) listAPIKeys func(ctx context.Context, jwt string) ([]APIKey, error) + logout func(ctx context.Context, jwt string, allDevices bool) error revokeAPIKey func(ctx context.Context, jwt string, keyID string) error } @@ -83,6 +84,14 @@ func (s *stubAuthClient) ListAPIKeys(ctx context.Context, jwt string) ([]APIKey, return s.listAPIKeys(ctx, jwt) } +func (s *stubAuthClient) Logout(ctx context.Context, jwt string, allDevices bool) error { + if s.logout == nil { + panic("Logout not stubbed") + } + + return s.logout(ctx, jwt, allDevices) +} + func (s *stubAuthClient) RevokeAPIKey(ctx context.Context, jwt string, keyID string) error { if s.revokeAPIKey == nil { // pragma: allowlist secret panic("RevokeAPIKey not stubbed") diff --git a/cli/internal/auth/platform_client.go b/cli/internal/auth/platform_client.go index 5cef55f..a7c749b 100644 --- a/cli/internal/auth/platform_client.go +++ b/cli/internal/auth/platform_client.go @@ -55,6 +55,13 @@ type Client interface { // OAuthProviders returns the providers configured on the platform. OAuthProviders(ctx context.Context) ([]Provider, error) + // Logout asks the platform to invalidate the current session. + // allDevices requests invalidation across every device/session + // associated with the account when supported by the platform. + // Returns ErrSessionExpired (401) when the JWT is invalid, or + // ErrLogoutUnsupported when the platform does not expose logout. + Logout(ctx context.Context, jwt string, allDevices bool) error + // RevokeAPIKey marks the named key revoked. The operation is // idempotent: revoking an already-revoked key still returns nil. // Returns ErrSessionExpired (401) or *APIKeyNotFoundError (404) on From 1ac457953bd3c604e2414ac29f6d2eea892238c9 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Thu, 14 May 2026 12:36:16 +0100 Subject: [PATCH 2/2] fix(cli): align logout revoke docs and idempotent flow --- cli/README.md | 2 +- cli/internal/auth/logout.go | 8 ++++---- cli/internal/auth/logout_test.go | 8 ++++++++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/cli/README.md b/cli/README.md index 93741af..57b0bd7 100644 --- a/cli/README.md +++ b/cli/README.md @@ -93,7 +93,7 @@ cq auth logout --revoke --all-devices `cq auth logout` behavior: - default: local-only credential cleanup -- `--revoke`: call `/auth/logout` before local cleanup +- `--revoke`: request server-side logout before local cleanup - `--revoke --all-devices`: request logout across all devices If server revocation fails (other than an already-invalid session), local credentials are kept so you can retry. diff --git a/cli/internal/auth/logout.go b/cli/internal/auth/logout.go index a83febd..850b5fc 100644 --- a/cli/internal/auth/logout.go +++ b/cli/internal/auth/logout.go @@ -56,10 +56,6 @@ func Logout(ctx context.Context, p LogoutConfig) error { return clearAndReport("Signed out (local credentials cleared).") } - if p.Client == nil { - return errors.New("auth: Logout with Revoke requires Client") - } - creds, err := p.Store.Load() if err != nil { if errors.Is(err, credstore.ErrNotFound) { @@ -75,6 +71,10 @@ func Logout(ctx context.Context, p LogoutConfig) error { return clearAndReport("Signed out (local credentials cleared).") } + if p.Client == nil { + return errors.New("auth: Logout with Revoke requires Client") + } + err = p.Client.Logout(ctx, creds.SessionJWT, p.AllDevices) if err != nil { if errors.Is(err, ErrSessionExpired) { diff --git a/cli/internal/auth/logout_test.go b/cli/internal/auth/logout_test.go index 8b27ad9..a105f38 100644 --- a/cli/internal/auth/logout_test.go +++ b/cli/internal/auth/logout_test.go @@ -32,6 +32,14 @@ func TestLogout_NotSignedInIsIdempotent(t *testing.T) { require.Contains(t, out.String(), "Signed out") } +func TestLogout_RevokeNotSignedIn_DoesNotRequireClient(t *testing.T) { + store := newMemStore() + out := &bytes.Buffer{} + + require.NoError(t, Logout(context.Background(), LogoutConfig{Store: store, Revoke: true, Out: out})) + require.Contains(t, out.String(), "already absent") +} + func TestLogout_RevokeSuccess_ClearsCredentials(t *testing.T) { store := newMemStore() require.NoError(t, store.Save(credstore.Credentials{SessionJWT: "j", Username: "alice"}))