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
38 changes: 19 additions & 19 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -130,27 +130,27 @@ fi
# ---------------------------------------------------------------------------
# Verify checksum (sha256)
# ---------------------------------------------------------------------------
if curl -fsSL "$checksum_url" -o "$tmp/checksums.txt" 2>/dev/null; then
expected=$(grep " ${asset}\$" "$tmp/checksums.txt" | awk '{print $1}')
if [ -n "$expected" ]; then
if has sha256sum; then
actual=$(sha256sum "$tmp/$asset" | awk '{print $1}')
elif has shasum; then
actual=$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')
else
warn "no sha256 tool found — skipping checksum verification"
actual=""
fi
if [ -n "$actual" ] && [ "$expected" != "$actual" ]; then
err "checksum mismatch (expected=${expected} got=${actual})"
fi
[ -n "$actual" ] && step "Verified checksum"
else
warn "${asset} not listed in checksums.txt — skipping verification"
fi
if ! curl -fsSL "$checksum_url" -o "$tmp/checksums.txt" 2>/dev/null; then
err "failed to download checksums.txt; refusing to install without checksum verification"
fi

expected=$(grep " ${asset}\$" "$tmp/checksums.txt" | awk '{print $1}')
if [ -z "$expected" ]; then
err "${asset} not listed in checksums.txt; refusing to install without checksum verification"
fi

if has sha256sum; then
actual=$(sha256sum "$tmp/$asset" | awk '{print $1}')
elif has shasum; then
actual=$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')
else
warn "could not fetch checksums.txt — skipping verification"
err "no sha256 tool found; install sha256sum or shasum and retry"
fi

if [ "$expected" != "$actual" ]; then
err "checksum mismatch (expected=${expected} got=${actual})"
fi
step "Verified checksum"

# ---------------------------------------------------------------------------
# Extract and install
Expand Down
7 changes: 5 additions & 2 deletions internal/cmd/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"net/url"
"os"
"strings"

"github.com/chatwoot/cli/internal/output"
)

type ApiCmd struct {
Expand Down Expand Up @@ -131,8 +133,9 @@ func printAPIResponse(w io.Writer, body []byte) {
return
}

_, _ = w.Write(body)
if body[len(body)-1] != '\n' {
safeBody := output.SanitizeText(string(body))
_, _ = io.WriteString(w, safeBody)
if !strings.HasSuffix(safeBody, "\n") {
_, _ = fmt.Fprintln(w)
}
}
16 changes: 16 additions & 0 deletions internal/cmd/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,22 @@ func TestApiCmdSendsMethodBodyAndHeaders(t *testing.T) {
}
}

func TestPrintAPIResponseSanitizesNonJSONBody(t *testing.T) {
var out bytes.Buffer

printAPIResponse(&out, []byte("hello\x1b]52;c;Zm9v\aworld\x1b[31m"))

got := out.String()
for _, disallowed := range []string{"\x1b", "\a", "]52", "[31m"} {
if strings.Contains(got, disallowed) {
t.Fatalf("non-JSON API response contained terminal control %q: %q", disallowed, got)
}
}
if !strings.Contains(got, "helloworld") {
t.Fatalf("non-JSON API response stripped printable content: %q", got)
}
}

func TestNormalizeAPIPathRejectsAbsoluteURLs(t *testing.T) {
if _, _, err := normalizeAPIPath("https://example.com/api/v1/profile", false); err == nil {
t.Fatal("expected absolute URL error")
Expand Down
12 changes: 7 additions & 5 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,14 @@ func (c *AuthLoginCmd) Run(app *App) error {
return fmt.Errorf("failed to save config: %w", err)
}

fmt.Printf("Logged in as %s (%s)\n", profile.Name, profile.Email)
fmt.Print(loginSuccessMessage(profile.Name, profile.Email))
return nil
}

func loginSuccessMessage(name, email string) string {
return fmt.Sprintf("Logged in as %s (%s)\n", output.SanitizeText(name), output.SanitizeText(email))
}

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

Expand Down Expand Up @@ -111,10 +115,8 @@ func (c *AuthLogoutCmd) Run(app *App) error {
return err
}

if cfg != nil {
if err := config.DeleteAPIKey(cfg); err != nil {
return err
}
if err := config.DeleteAPIKey(cfg); err != nil {
return err
}

if err := os.Remove(path); err != nil {
Expand Down
34 changes: 34 additions & 0 deletions internal/cmd/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"bytes"
"errors"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -112,6 +113,18 @@ func TestAuthStatusReportsIdentityAndCredentialSource(t *testing.T) {
}
}

func TestLoginSuccessMessageStripsTerminalControls(t *testing.T) {
got := loginSuccessMessage("Eve\x1b]52;c;Zm9v\a", "eve@example.com\x1b[31m")
for _, disallowed := range []string{"\x1b", "\a", "]52", "[31m"} {
if strings.Contains(got, disallowed) {
t.Fatalf("login success message contained terminal control %q: %q", disallowed, got)
}
}
if !strings.Contains(got, "Logged in as Eve (eve@example.com)") {
t.Fatalf("login success message stripped printable content: %q", got)
}
}

func TestMeAndWhoamiAliasAuthStatus(t *testing.T) {
profile := `{
"id": 7,
Expand Down Expand Up @@ -152,6 +165,27 @@ 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)
}

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

func TestAuthStatusSelfHealsCachedUserID(t *testing.T) {
profile := `{
"id": 99,
Expand Down
89 changes: 72 additions & 17 deletions internal/config/credentials.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"encoding/json"
"errors"
"fmt"
"os"
Expand All @@ -10,8 +11,9 @@ import (
)

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

type CredentialSource string
Expand All @@ -24,6 +26,12 @@ const (

var ErrAPIKeyNotFound = errors.New("api key not found")

type savedCredential struct {
BaseURL string `json:"base_url"`
AccountID int `json:"account_id"`
APIKey string `json:"api_key"`
}

// ResolveAPIKey implements the auth flow for the CLI. YAML config intentionally
// stores only non-secrets, and plaintext api_key values from older configs are
// ignored. CHATWOOT_API_KEY wins for CI, coding agents, and temporary overrides;
Expand All @@ -37,14 +45,33 @@ func ResolveAPIKey(cfg *Config) (string, CredentialSource, error) {
return "", CredentialSourceMissing, missingAPIKeyError()
}

apiKey, err := keyring.Get(keyringService, credentialKey(cfg))
stored, err := keyring.Get(keyringService, apiKeyKeyringEntry)
if err == nil {
apiKey, err := parseSavedCredential(stored, cfg)
if err != nil {
return "", CredentialSourceMissing, err
}
return apiKey, CredentialSourceKeyring, nil
}
if errors.Is(err, keyring.ErrNotFound) {
return "", CredentialSourceMissing, missingAPIKeyError()
if !errors.Is(err, keyring.ErrNotFound) {
return "", CredentialSourceMissing, fmt.Errorf("failed to read API key from keyring: %w", err)
}
return "", CredentialSourceMissing, fmt.Errorf("failed to read API key from keyring: %w", err)

// TODO(v1): remove this legacy key migration after users have had a release
// cycle to move from URL/account-scoped keyring entries to api-key.
apiKey, err := keyring.Get(keyringService, legacyCredentialKey(cfg))
if err == nil {
if err := saveAPIKeyToKeyring(cfg, apiKey); err != nil {
return "", CredentialSourceMissing, fmt.Errorf("failed to migrate API key in keyring: %w", err)
}
_ = keyring.Delete(keyringService, legacyCredentialKey(cfg))
return apiKey, CredentialSourceKeyring, nil
}
if !errors.Is(err, keyring.ErrNotFound) {
return "", CredentialSourceMissing, fmt.Errorf("failed to read legacy API key from keyring: %w", err)
}

return "", CredentialSourceMissing, missingAPIKeyError()
}

func SaveAPIKey(cfg *Config, apiKey string) error {
Expand All @@ -55,30 +82,58 @@ func SaveAPIKey(cfg *Config, apiKey string) error {
if cfg == nil || !cfg.IsValid() {
return fmt.Errorf("valid config is required to save API key")
}
if err := keyring.Set(keyringService, credentialKey(cfg), apiKey); err != nil {
if err := saveAPIKeyToKeyring(cfg, apiKey); err != nil {
return fmt.Errorf("failed to save API key to keyring: %w", err)
}
return nil
}

func DeleteAPIKey(cfg *Config) error {
if cfg == nil || !cfg.IsValid() {
return nil
func saveAPIKeyToKeyring(cfg *Config, apiKey string) error {
credential := savedCredential{
BaseURL: normalizeBaseURL(cfg.BaseURL),
AccountID: cfg.AccountID,
APIKey: apiKey,
}
if err := keyring.Delete(keyringService, credentialKey(cfg)); err != nil {
if errors.Is(err, keyring.ErrNotFound) {
return nil
}
return fmt.Errorf("failed to delete API key from keyring: %w", err)

data, err := json.Marshal(credential)
if err != nil {
return err
}
return nil
return keyring.Set(keyringService, apiKeyKeyringEntry, string(data))
}

func parseSavedCredential(stored string, cfg *Config) (string, error) {
var credential savedCredential
if err := json.Unmarshal([]byte(stored), &credential); err != nil {
return "", credentialScopeMismatchError()
}
if credential.APIKey == "" ||
credential.BaseURL != normalizeBaseURL(cfg.BaseURL) ||
credential.AccountID != cfg.AccountID {
return "", credentialScopeMismatchError()
}
return credential.APIKey, nil
}

// DeleteAPIKey removes every credential saved by this CLI service. This avoids
// leaving stale keyring entries behind when config.yaml was edited or removed.
func DeleteAPIKey(_ *Config) error {
err := keyring.DeleteAll(keyringService)
if err == nil || errors.Is(err, keyring.ErrNotFound) {
return nil
}
return fmt.Errorf("failed to delete API keys from keyring: %w", err)
}

func missingAPIKeyError() error {
return fmt.Errorf("%w; run 'chatwoot auth login' or set %s", ErrAPIKeyNotFound, APIKeyEnv)
}

func credentialKey(cfg *Config) string {
func credentialScopeMismatchError() error {
return fmt.Errorf("%w; saved keyring credential does not match configured instance; run 'chatwoot auth login' for this base URL and account", ErrAPIKeyNotFound)
}

func legacyCredentialKey(cfg *Config) string {
return fmt.Sprintf("%s/accounts/%d", normalizeBaseURL(cfg.BaseURL), cfg.AccountID)
}

Expand Down
Loading
Loading