diff --git a/README.md b/README.md index e3ced6f..a0fe75b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A command-line interface for the Linear API, built with Go and Cobra. ## Features -- **Authentication**: personal API key auth (`linctl auth`) and env-var override support. +- **Authentication**: personal API key auth (`linctl auth`), env-var override, and optional [`pass`](https://www.passwordstore.org/) credential storage. - **Issues**: list/search/get/create/update/assign with support for: - cycles, labels, delegation, projects/milestones, parent/sub-issue links - due dates, attachments, comments, and rich issue detail output @@ -723,7 +723,9 @@ api: retries: 3 ``` -Authentication credentials are stored securely in `~/.linctl-auth.json`. +By default, authentication credentials are stored in `~/.linctl-auth.json`. +Users of the external [`pass`](https://www.passwordstore.org/) password manager +can opt in to GPG-backed credential storage with `LINCTL_PASS_NAME`. ## Authentication @@ -751,7 +753,28 @@ linctl whoami unset LINCTL_API_KEY ``` -Precedence: `LINCTL_API_KEY` environment variable > config file (`~/.linctl-auth.json`). +### Storing the Key in `pass` (Optional) + +If you already use the external [`pass`](https://www.passwordstore.org/) +password manager, set `LINCTL_PASS_NAME` to the entry name and `linctl` will +read/write the key through `pass` instead of the JSON config file. If +`LINCTL_PASS_NAME` is unset, this feature is disabled and existing auth behavior +is unchanged. + +```bash +# One-time setup +export LINCTL_PASS_NAME=linear-api-key +linctl auth # stores via `pass insert -m -f -- linear-api-key` + +# Subsequent calls just work +linctl whoami +``` + +`linctl logout` removes the entry via `pass rm -f -- linear-api-key` and also +removes any legacy `~/.linctl-auth.json` file if one exists. + +Precedence: `LINCTL_API_KEY` environment variable > `pass` (when +`LINCTL_PASS_NAME` is set) > config file (`~/.linctl-auth.json`). ## Time-based Filtering diff --git a/cmd/auth.go b/cmd/auth.go index 2c2071a..c440e7d 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -17,11 +17,16 @@ var authCmd = &cobra.Command{ Short: "Authenticate with Linear", Long: `Authenticate with Linear using Personal API Key. +By default, linctl stores credentials in ~/.linctl-auth.json. Users of the +external pass password manager can opt in by setting LINCTL_PASS_NAME to the +desired pass entry name before running auth commands. + Examples: linctl auth # Interactive authentication linctl auth login # Same as above linctl auth status # Check authentication status - linctl auth logout # Clear stored credentials`, + linctl auth logout # Clear stored credentials + LINCTL_PASS_NAME=linear-api-key linctl auth`, Run: func(cmd *cobra.Command, args []string) { // Default behavior is to run login loginCmd.Run(cmd, args) @@ -31,7 +36,11 @@ Examples: var loginCmd = &cobra.Command{ Use: "login", Short: "Login to Linear", - Long: `Authenticate with Linear using Personal API Key.`, + Long: `Authenticate with Linear using Personal API Key. + +By default, linctl stores credentials in ~/.linctl-auth.json. Users of the +external pass password manager can opt in by setting LINCTL_PASS_NAME to the +desired pass entry name.`, Run: func(cmd *cobra.Command, args []string) { plaintext := viper.GetBool("plaintext") jsonOut := viper.GetBool("json") @@ -101,7 +110,10 @@ var statusCmd = &cobra.Command{ var logoutCmd = &cobra.Command{ Use: "logout", Short: "Logout from Linear", - Long: `Clear stored Linear credentials.`, + Long: `Clear stored Linear credentials. + +By default, this removes ~/.linctl-auth.json. When LINCTL_PASS_NAME is set, +linctl also removes that pass entry.`, Run: func(cmd *cobra.Command, args []string) { plaintext := viper.GetBool("plaintext") jsonOut := viper.GetBool("json") diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 6773681..18f9716 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -4,8 +4,11 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" + "io" "os" + "os/exec" "path/filepath" "strings" @@ -13,6 +16,61 @@ import ( "github.com/fatih/color" ) +// passEntryName returns the pass entry name to use, or "" if pass storage +// is not configured. Setting LINCTL_PASS_NAME= opts the user into +// storing the API key in `pass` instead of the JSON config file. +func passEntryName() string { + return strings.TrimSpace(os.Getenv("LINCTL_PASS_NAME")) +} + +var runPassCommand = func(stdin io.Reader, args ...string) ([]byte, error) { + cmd := exec.Command("pass", args...) + cmd.Stdin = stdin + return cmd.CombinedOutput() +} + +func readFromPass(name string) (string, error) { + out, err := runPassCommand(nil, "show", "--", name) + if err != nil { + if strings.TrimSpace(string(out)) != "" { + return "", fmt.Errorf("pass show %s: %s", name, strings.TrimSpace(string(out))) + } + return "", fmt.Errorf("pass show %s: %w", name, err) + } + scanner := bufio.NewScanner(strings.NewReader(string(out))) + for scanner.Scan() { + if line := strings.TrimSpace(scanner.Text()); line != "" { + return line, nil + } + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", nil +} + +func writeToPass(name, value string) error { + out, err := runPassCommand(strings.NewReader(value+"\n"), "insert", "-m", "-f", "--", name) + if err != nil { + if strings.TrimSpace(string(out)) == "" { + return fmt.Errorf("pass insert %s: %w", name, err) + } + return fmt.Errorf("pass insert %s: %s", name, strings.TrimSpace(string(out))) + } + return nil +} + +func removeFromPass(name string) error { + out, err := runPassCommand(nil, "rm", "-f", "--", name) + if err != nil { + if strings.TrimSpace(string(out)) == "" { + return fmt.Errorf("pass rm %s: %w", name, err) + } + return fmt.Errorf("pass rm %s: %s", name, strings.TrimSpace(string(out))) + } + return nil +} + type User struct { ID string `json:"id"` Name string `json:"name"` @@ -48,6 +106,17 @@ func saveAuth(config AuthConfig) error { return os.WriteFile(configPath, data, 0600) } +func removeAuthConfig() error { + configPath, err := getConfigPath() + if err != nil { + return err + } + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + // loadAuth loads authentication credentials func loadAuth() (*AuthConfig, error) { configPath, err := getConfigPath() @@ -72,15 +141,23 @@ func loadAuth() (*AuthConfig, error) { return &config, nil } -// GetAuthHeader returns the authorization header value -// Precedence: LINCTL_API_KEY env var > config file +// GetAuthHeader returns the authorization header value. +// Precedence: LINCTL_API_KEY env var > pass (when LINCTL_PASS_NAME set) > config file. func GetAuthHeader() (string, error) { - // Check environment variable first (useful for CI/CD or temporary overrides) if envKey := strings.TrimSpace(os.Getenv("LINCTL_API_KEY")); envKey != "" { return envKey, nil } - // Fall back to config file + if entry := passEntryName(); entry != "" { + key, err := readFromPass(entry) + if err != nil { + return "", err + } + if key != "" { + return key, nil + } + } + config, err := loadAuth() if err != nil { return "", err @@ -104,9 +181,14 @@ func loginWithAPIKey(plaintext, jsonOut bool) error { fmt.Println("\n" + color.New(color.FgYellow).Sprint("📝 Personal API Key Authentication")) fmt.Println("Get your API key from: https://linear.app//settings/account/security") - // Get the config path to show to the user - configPath, _ := getConfigPath() - fmt.Printf("Your credentials will be stored in: %s\n", color.New(color.FgCyan).Sprint(configPath)) + var location string + if entry := passEntryName(); entry != "" { + location = fmt.Sprintf("pass entry %q", entry) + } else { + configPath, _ := getConfigPath() + location = configPath + } + fmt.Printf("Your credentials will be stored in: %s\n", color.New(color.FgCyan).Sprint(location)) fmt.Print("\nEnter your Personal API Key: ") } @@ -128,13 +210,17 @@ func loginWithAPIKey(plaintext, jsonOut bool) error { return fmt.Errorf("invalid API key: %v", err) } - // Save the API key - config := AuthConfig{ - APIKey: apiKey, - } - err = saveAuth(config) - if err != nil { - return err + if entry := passEntryName(); entry != "" { + if err := writeToPass(entry, apiKey); err != nil { + return err + } + if err := removeAuthConfig(); err != nil { + return err + } + } else { + if err := saveAuth(AuthConfig{APIKey: apiKey}); err != nil { + return err + } } if !plaintext && !jsonOut { @@ -169,17 +255,16 @@ func GetCurrentUser() (*User, error) { }, nil } -// Logout clears stored credentials +// Logout clears stored credentials. When LINCTL_PASS_NAME is set, the pass +// entry is removed; the legacy JSON file is also removed if present so a +// future re-login starts from a clean slate. func Logout() error { - configPath, err := getConfigPath() - if err != nil { - return err - } - - err = os.Remove(configPath) - if err != nil && !os.IsNotExist(err) { - return err + var passErr error + if entry := passEntryName(); entry != "" { + if err := removeFromPass(entry); err != nil { + passErr = err + } } - return nil + return errors.Join(passErr, removeAuthConfig()) } diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go new file mode 100644 index 0000000..01ac117 --- /dev/null +++ b/pkg/auth/auth_test.go @@ -0,0 +1,188 @@ +package auth + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func withTempHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + return home +} + +func withMockPass(t *testing.T, fn func(stdin io.Reader, args ...string) ([]byte, error)) { + t.Helper() + orig := runPassCommand + runPassCommand = fn + t.Cleanup(func() { + runPassCommand = orig + }) +} + +func writeStdin(t *testing.T, value string) func() { + t.Helper() + orig := os.Stdin + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("create stdin pipe: %v", err) + } + if _, err := writer.WriteString(value); err != nil { + t.Fatalf("write stdin pipe: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close stdin writer: %v", err) + } + os.Stdin = reader + return func() { + os.Stdin = orig + _ = reader.Close() + } +} + +func mockViewer(t *testing.T) func() { + t.Helper() + orig := http.DefaultTransport + http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + if got := req.Header.Get("Authorization"); got != "new-key" { + t.Fatalf("expected auth header new-key, got %q", got) + } + + var gqlReq struct { + Query string `json:"query"` + } + if err := json.NewDecoder(req.Body).Decode(&gqlReq); err != nil { + t.Fatalf("decode GraphQL request: %v", err) + } + if !strings.Contains(gqlReq.Query, "viewer") { + t.Fatalf("expected viewer query, got %q", gqlReq.Query) + } + + body := `{"data":{"viewer":{"id":"u1","name":"Test User","email":"test@example.com","avatarUrl":"","isMe":true,"active":true,"admin":false}}}` + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }) + return func() { + http.DefaultTransport = orig + } +} + +func TestGetAuthHeaderEnvOverridesPassAndConfig(t *testing.T) { + withTempHome(t) + t.Setenv("LINCTL_API_KEY", "env-key") + t.Setenv("LINCTL_PASS_NAME", "linear-api-key") + if err := saveAuth(AuthConfig{APIKey: "config-key"}); err != nil { + t.Fatalf("saveAuth: %v", err) + } + + withMockPass(t, func(stdin io.Reader, args ...string) ([]byte, error) { + t.Fatalf("pass should not be called when LINCTL_API_KEY is set") + return nil, nil + }) + + got, err := GetAuthHeader() + if err != nil { + t.Fatalf("GetAuthHeader returned error: %v", err) + } + if got != "env-key" { + t.Fatalf("expected env-key, got %q", got) + } +} + +func TestGetAuthHeaderReadsFirstPassLineWithOptionTerminator(t *testing.T) { + withTempHome(t) + t.Setenv("LINCTL_PASS_NAME", "-linear-api-key") + + withMockPass(t, func(stdin io.Reader, args ...string) ([]byte, error) { + want := []string{"show", "--", "-linear-api-key"} + if !reflect.DeepEqual(args, want) { + t.Fatalf("expected args %v, got %v", want, args) + } + return []byte("lin_api_x\nnotes should not be part of the header\n"), nil + }) + + got, err := GetAuthHeader() + if err != nil { + t.Fatalf("GetAuthHeader returned error: %v", err) + } + if got != "lin_api_x" { + t.Fatalf("expected first pass line, got %q", got) + } +} + +func TestLoginWithPassRemovesLegacyConfig(t *testing.T) { + home := withTempHome(t) + t.Setenv("LINCTL_PASS_NAME", "linear-api-key") + if err := saveAuth(AuthConfig{APIKey: "old-config-key"}); err != nil { + t.Fatalf("saveAuth: %v", err) + } + defer mockViewer(t)() + defer writeStdin(t, "new-key\n")() + + passInsertCalled := false + withMockPass(t, func(stdin io.Reader, args ...string) ([]byte, error) { + want := []string{"insert", "-m", "-f", "--", "linear-api-key"} + if !reflect.DeepEqual(args, want) { + t.Fatalf("expected args %v, got %v", want, args) + } + data, err := io.ReadAll(stdin) + if err != nil { + t.Fatalf("read pass stdin: %v", err) + } + if string(data) != "new-key\n" { + t.Fatalf("expected pass stdin to contain API key only, got %q", string(data)) + } + passInsertCalled = true + return []byte{}, nil + }) + + if err := Login(true, false); err != nil { + t.Fatalf("Login returned error: %v", err) + } + if !passInsertCalled { + t.Fatalf("expected pass insert to be called") + } + if _, err := os.Stat(filepath.Join(home, ".linctl-auth.json")); !os.IsNotExist(err) { + t.Fatalf("expected legacy auth config to be removed, stat err=%v", err) + } +} + +func TestLogoutRemovesConfigEvenWhenPassRemoveFails(t *testing.T) { + home := withTempHome(t) + t.Setenv("LINCTL_PASS_NAME", "linear-api-key") + if err := saveAuth(AuthConfig{APIKey: "old-config-key"}); err != nil { + t.Fatalf("saveAuth: %v", err) + } + + withMockPass(t, func(stdin io.Reader, args ...string) ([]byte, error) { + want := []string{"rm", "-f", "--", "linear-api-key"} + if !reflect.DeepEqual(args, want) { + t.Fatalf("expected args %v, got %v", want, args) + } + return []byte("pass entry missing"), errors.New("pass failed") + }) + + if err := Logout(); err == nil { + t.Fatalf("expected Logout to report pass rm failure") + } + if _, err := os.Stat(filepath.Join(home, ".linctl-auth.json")); !os.IsNotExist(err) { + t.Fatalf("expected legacy auth config to be removed despite pass failure, stat err=%v", err) + } +}