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
4 changes: 1 addition & 3 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
module github.com/chatwoot/cli

go 1.25.5

toolchain go1.25.10
go 1.26.4

require (
github.com/alecthomas/kong v1.15.0
Expand Down
38 changes: 38 additions & 0 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ func (c *AuthLoginCmd) Run(app *App) error {
if err != nil {
return fmt.Errorf("authentication failed: %w", err)
}

// The token is valid, but that doesn't mean it can access the account ID the
// user typed. Verify membership now so a typo/wrong account fails here with a
// clear message instead of as a cryptic 404 on the first account-scoped call.
if err := verifyAccountAccess(profile, cfg.AccountID); err != nil {
return err
}
cfg.UserID = profile.ID

if err := config.SaveAPIKey(cfg, apiKey); err != nil {
Expand All @@ -82,6 +89,37 @@ func loginSuccessMessage(name, email string) string {
return fmt.Sprintf("Logged in as %s (%s)\n", output.SanitizeText(name), output.SanitizeText(email))
}

// verifyAccountAccess fails login when the entered account ID is not one the
// authenticated user belongs to. The profile payload's accounts array is the
// source of truth. If the instance returns no accounts (older Chatwoot, or a
// token type that omits them), the check is skipped rather than block login.
func verifyAccountAccess(profile *sdk.ProfileResponse, accountID int) error {
if len(profile.Accounts) == 0 {
return nil
}
for _, acc := range profile.Accounts {
if acc.ID == accountID {
return nil
}
}
return fmt.Errorf("account %d is not accessible with this API key; %s", accountID, accessibleAccountsHint(profile.Accounts))
}

func accessibleAccountsHint(accounts []sdk.ProfileAccount) string {
parts := make([]string, 0, len(accounts))
for _, acc := range accounts {
if name := output.SanitizeText(acc.Name); name != "" {
parts = append(parts, fmt.Sprintf("%d (%s)", acc.ID, name))
} else {
parts = append(parts, strconv.Itoa(acc.ID))
}
}
if len(parts) == 1 {
return "this key can access account " + parts[0]
}
return "this key can access accounts: " + strings.Join(parts, ", ")
}

func readAPIKey(reader *bufio.Reader) (string, error) {
fmt.Print("API Key: ")

Expand Down
150 changes: 143 additions & 7 deletions internal/cmd/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ package cmd
import (
"bytes"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"

"github.com/chatwoot/cli/internal/config"
"github.com/chatwoot/cli/internal/output"
"github.com/chatwoot/cli/internal/sdk"
"github.com/zalando/go-keyring"
)

Expand Down Expand Up @@ -125,6 +128,33 @@ func TestLoginSuccessMessageStripsTerminalControls(t *testing.T) {
}
}

func TestVerifyAccountAccess(t *testing.T) {
accounts := []sdk.ProfileAccount{
{ID: 7, Name: "Acme", Role: "administrator"},
{ID: 9, Name: "Beta", Role: "agent"},
}

if err := verifyAccountAccess(&sdk.ProfileResponse{Accounts: accounts}, 9); err != nil {
t.Fatalf("expected access to a member account, got error: %v", err)
}

err := verifyAccountAccess(&sdk.ProfileResponse{Accounts: accounts}, 42)
if err == nil {
t.Fatal("expected error for non-member account, got nil")
}
// The message should name the accessible accounts so the user can correct the ID.
for _, want := range []string{"42", "7 (Acme)", "9 (Beta)"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error %q missing %q", err.Error(), want)
}
}

// No accounts in payload (older instances) → skip rather than block login.
if err := verifyAccountAccess(&sdk.ProfileResponse{}, 42); err != nil {
t.Fatalf("expected skip when no accounts present, got error: %v", err)
}
}

func TestMeAndWhoamiAliasAuthStatus(t *testing.T) {
profile := `{
"id": 7,
Expand Down Expand Up @@ -167,22 +197,23 @@ func TestAuthStatusNotLoggedIn(t *testing.T) {

func TestAuthLogoutRemovesKeyringTokenWithoutConfig(t *testing.T) {
keyring.MockInit()
if err := keyring.DeleteAll("chatwoot-cli"); err != nil {
t.Fatalf("keyring.DeleteAll: %v", err)
}
t.Setenv("HOME", t.TempDir())
t.Setenv(config.APIKeyEnv, "")

if err := keyring.Set("chatwoot-cli", "api-key", "stale-token"); err != nil {
t.Fatalf("keyring.Set: %v", err)
// Seed the token through the production path so it lands under whichever
// keyring service the active build profile uses (prod vs dev), without
// writing config.yaml — this exercises logout with no config present.
seed := &config.Config{BaseURL: "https://app.chatwoot.com", AccountID: 1}
if err := config.SaveAPIKey(seed, "stale-token"); err != nil {
t.Fatalf("SaveAPIKey: %v", err)
}

if err := (&AuthLogoutCmd{}).Run(&App{}); err != nil {
t.Fatalf("Run: %v", err)
}

if _, err := keyring.Get("chatwoot-cli", "api-key"); !errors.Is(err, keyring.ErrNotFound) {
t.Fatalf("expected logout to delete stale keyring token, err = %v", err)
if _, _, err := config.ResolveAPIKey(seed); !errors.Is(err, config.ErrAPIKeyNotFound) {
t.Fatalf("expected logout to delete the keyring token, err = %v", err)
}
}

Expand Down Expand Up @@ -244,3 +275,108 @@ func TestAuthStatusDoesNotCacheUserIDFromEnvironmentToken(t *testing.T) {
t.Fatalf("expected env-token auth status to preserve cached UserID=42, got %d", post.UserID)
}
}

// TestAuthLoginVerifiesAccountAccess drives the full `auth login` flow (stdin →
// profile fetch → membership check → persist) to cover the wiring, not just the
// verifyAccountAccess helper.
func TestAuthLoginVerifiesAccountAccess(t *testing.T) {
profileBody := `{"id":5,"name":"Eve","email":"eve@example.com","availability_status":"online","role":"agent",` +
`"accounts":[{"id":7,"name":"Acme","role":"administrator"},{"id":9,"name":"Beta","role":"agent"}]}`

t.Run("rejects an account the token cannot access", func(t *testing.T) {
server := loginProfileServer(t, profileBody)
defer server.Close()
isolateAuthEnv(t)

err := runLogin(t, server.URL+"\ntoken\n42\n")
if err == nil {
t.Fatal("expected login to fail for an inaccessible account")
}
for _, want := range []string{"42", "Acme", "Beta"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error %q should name entered + accessible accounts", err.Error())
}
}
// Nothing must be persisted when login is rejected.
if cfg, _ := config.Load(); cfg != nil {
t.Fatalf("config was saved despite a rejected login: %#v", cfg)
}
})

t.Run("accepts a member account and persists config + key", func(t *testing.T) {
server := loginProfileServer(t, profileBody)
defer server.Close()
isolateAuthEnv(t)

if err := runLogin(t, server.URL+"\ntoken\n7\n"); err != nil {
t.Fatalf("login: %v", err)
}

cfg, err := config.Load()
if err != nil || cfg == nil {
t.Fatalf("config not saved: cfg=%#v err=%v", cfg, err)
}
if cfg.AccountID != 7 || cfg.UserID != 5 {
t.Fatalf("saved cfg = %#v, want AccountID 7, UserID 5", cfg)
}
apiKey, source, err := config.ResolveAPIKey(cfg)
if err != nil || apiKey != "token" || source != config.CredentialSourceKeyring {
t.Fatalf("ResolveAPIKey = (%q, %v, %v), want token/keyring", apiKey, source, err)
}
})
}

// isolateAuthEnv gives a test its own HOME + mocked keyring and clears the
// CHATWOOT_API_KEY override so credential resolution exercises the keyring path.
func isolateAuthEnv(t *testing.T) {
t.Helper()
keyring.MockInit()
if err := keyring.DeleteAll("chatwoot-cli"); err != nil {
t.Fatalf("keyring.DeleteAll: %v", err)
}
t.Setenv("HOME", t.TempDir())
t.Setenv(config.APIKeyEnv, "")
}

func loginProfileServer(t *testing.T, body string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/profile" {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
}

// runLogin feeds scripted answers to the interactive login prompts via os.Stdin
// and silences the prompt/banner output, returning the command's error.
func runLogin(t *testing.T, stdin string) error {
t.Helper()

r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
if _, err := io.WriteString(w, stdin); err != nil {
t.Fatalf("write stdin: %v", err)
}
_ = w.Close()

devnull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
t.Fatalf("open devnull: %v", err)
}

oldStdin, oldStdout := os.Stdin, os.Stdout
os.Stdin, os.Stdout = r, devnull
defer func() {
os.Stdin, os.Stdout = oldStdin, oldStdout
_ = r.Close()
_ = devnull.Close()
}()

printer := output.NewPrinter("text", false, false)
return (&AuthLoginCmd{}).Run(&App{Printer: printer})
}
8 changes: 6 additions & 2 deletions internal/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,15 @@ func (c *ConfigViewCmd) Run(app *App) error {

credential := credentialStatus(cfg)

app.Printer.PrintDetail([]output.KeyValue{
detail := []output.KeyValue{
{Key: "Base URL", Value: cfg.BaseURL},
{Key: "Account ID", Value: fmt.Sprintf("%d", cfg.AccountID)},
{Key: "Credential", Value: credential},
})
}
if config.IsDev {
detail = append(detail, output.KeyValue{Key: "Profile", Value: "dev"})
}
app.Printer.PrintDetail(detail)

return nil
}
Expand Down
19 changes: 19 additions & 0 deletions internal/config/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@ Credential resolution and OS keyring storage. Provides:
- `SaveAPIKey()` — write validated login token to keyring
- `DeleteAPIKey()` — remove saved keyring token on logout

## Build Profiles (dev vs prod)

`configFileName` and `keyringService` are selected at build time via the `dev`
build tag (`profile_prod.go` for `//go:build !dev`, `profile_dev.go` for
`//go:build dev`):

| | config file | keyring service | `config.IsDev` |
|---|---|---|---|
| prod (default `go build`, releases) | `~/.chatwoot/config.yaml` | `chatwoot-cli` | `false` |
| dev (`go build -tags dev`, `mise run dev`) | `~/.chatwoot/config.dev.yaml` | `chatwoot-cli-dev` | `true` |

A dev build keeps its own credentials, so iterating on the CLI never reads or
clobbers the production login. The keyring **service** (not just the entry name)
is namespaced per profile, so `auth logout` — which does
`keyring.DeleteAll(keyringService)` to clear stale entries — only wipes the
active build's tokens and never the other profile's. Release builds (goreleaser
passes no tags) exclude `profile_dev.go` entirely — the dev path is compiled
out. `config view` shows a `Profile: dev` line on dev builds.

## Config Schema

```yaml
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func ConfigPath() (string, error) {
if err != nil {
return "", err
}
return filepath.Join(dir, "config.yaml"), nil
return filepath.Join(dir, configFileName), nil
}

func Load() (*Config, error) {
Expand Down
8 changes: 6 additions & 2 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package config

import (
"os"
"path/filepath"
"strings"
"testing"
)
Expand Down Expand Up @@ -93,7 +92,12 @@ func TestLegacyAPIKeyIsIgnoredAndRemovedOnSave(t *testing.T) {
t.Fatalf("MkdirAll() error = %v", err)
}

path := filepath.Join(dir, "config.yaml")
// Derive the path from ConfigPath() rather than hardcoding "config.yaml" so
// this test is correct under any build profile (e.g. dev → config.dev.yaml).
path, err := ConfigPath()
if err != nil {
t.Fatalf("ConfigPath() error = %v", err)
}
legacy := "base_url: https://app.chatwoot.com\napi_key: plaintext-token\naccount_id: 123\n"
if err := os.WriteFile(path, []byte(legacy), 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
Expand Down
7 changes: 6 additions & 1 deletion internal/config/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ import (

const (
APIKeyEnv = "CHATWOOT_API_KEY"
keyringService = "chatwoot-cli"
apiKeyKeyringEntry = "api-key"
)

// keyringService is build-profile specific ("chatwoot-cli" for prod,
// "chatwoot-cli-dev" for dev). Namespacing the whole service per profile keeps
// logout's DeleteAll(keyringService) scoped to the active build, so a dev logout
// can't erase a prod login's token and vice versa. See profile_prod.go /
// profile_dev.go.

type CredentialSource string

const (
Expand Down
37 changes: 37 additions & 0 deletions internal/config/credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,40 @@ func TestDeleteAPIKeyRemovesAllServiceEntries(t *testing.T) {
}
}
}

// TestDeleteAPIKeyLeavesOtherProfileServiceIntact guards the dev/prod isolation
// guarantee: keyringService is namespaced per build profile, so logging out of
// one build must not delete the other build's saved token. DeleteAPIKey wipes
// only keyringService, never another service's entries.
func TestDeleteAPIKeyLeavesOtherProfileServiceIntact(t *testing.T) {
initMockKeyring(t)
cfg := &Config{BaseURL: "https://app.chatwoot.com", AccountID: 130}

// Stand in for the other build profile's keyring namespace (prod's
// "chatwoot-cli" vs dev's "chatwoot-cli-dev"); the exact name doesn't matter,
// only that it differs from the active keyringService.
const otherProfileService = "chatwoot-cli-other-profile"
if err := keyring.Set(otherProfileService, apiKeyKeyringEntry, "other-profile-token"); err != nil {
t.Fatalf("seed other-profile service: %v", err)
}
if err := SaveAPIKey(cfg, "this-profile-token"); err != nil {
t.Fatalf("SaveAPIKey() error = %v", err)
}

if err := DeleteAPIKey(cfg); err != nil {
t.Fatalf("DeleteAPIKey() error = %v", err)
}

// Active profile's token is gone...
if _, _, err := ResolveAPIKey(cfg); !errors.Is(err, ErrAPIKeyNotFound) {
t.Fatalf("active profile token survived logout, err = %v", err)
}
// ...but the other profile's token must be untouched.
got, err := keyring.Get(otherProfileService, apiKeyKeyringEntry)
if err != nil {
t.Fatalf("other profile token erased by logout: %v", err)
}
if got != "other-profile-token" {
t.Fatalf("other profile token = %q, want other-profile-token", got)
}
}
Loading
Loading