Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`: 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.

### Authentication vs API keys

Expand Down
61 changes: 51 additions & 10 deletions cli/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions cli/cmd/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion cli/internal/auth/doc.go
Original file line number Diff line number Diff line change
@@ -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:
//
Expand Down
4 changes: 4 additions & 0 deletions cli/internal/auth/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 29 additions & 2 deletions cli/internal/auth/http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Comment thread
peteski22 marked this conversation as resolved.

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"
Expand All @@ -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 {
Comment thread
peteski22 marked this conversation as resolved.
return &APIKeyNotFoundError{KeyID: keyID}
}

Expand Down
65 changes: 64 additions & 1 deletion cli/internal/auth/http_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type captured struct {
sync.Mutex
method string
path string
query string
auth string
body []byte
}
Expand All @@ -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
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
Loading
Loading