From 13eaa746e2d4a2ef681cc47cba354135070a4731 Mon Sep 17 00:00:00 2001 From: Alexis Hildebrandt Date: Thu, 28 May 2026 08:24:17 +0200 Subject: [PATCH 1/2] feat: add support for storing access tokens in password-store --- README.md | 15 +++- cmd/auth/auth.go | 1 + cmd/auth/login.go | 58 +++++++++++-- cmd/auth/logout.go | 2 +- cmd/auth/status.go | 4 +- cmd/root.go | 3 +- internal/auth/auth.go | 47 +++++++++-- internal/auth/auth_test.go | 163 +++++++++++++++++++++++++++++++++++-- 8 files changed, 270 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index cd9e8b5..ec4efbc 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ sl auth login # Prompts for your API key ``` -### 3. 1Password integration (most secure) +### 3. 1Password integration If you use 1Password, the CLI can retrieve your API key on each request without ever storing it on disk: @@ -72,6 +72,16 @@ sl auth login --1password --vault Personal --item "SimpleLogin API Key" This stores an `op://` reference in the config file. The actual key is fetched via the `op` CLI each time it's needed. You must have the [1Password CLI](https://developer.1password.com/docs/cli/) installed and signed in. +### 4. password-store integration + +If you use [password-store](https://www.passwordstore.org/) (`pass`), the CLI can retrieve your API key the same way — without storing it on disk: + +```bash +sl auth login --pass --item "Email/simplelogin" +``` + +This stores a `ps://` reference in the config file. The actual key is fetched from the first line of `pass show ` each time it's needed. You must have `pass` installed and the entry must exist in your store. + ### Self-hosted instances If you run a self-hosted SimpleLogin instance, pass your URL at login: @@ -138,6 +148,7 @@ Most commands support `--json` and `--jq` flags for machine-readable output. Man | `sl auth login --key ` | Store API key directly | | `sl auth login --key --url ` | Authenticate against a self-hosted instance | | `sl auth login --1password --vault --item ` | Use 1Password integration | +| `sl auth login --pass --item ` | Use password-store integration | | `sl auth logout` | Remove stored credentials | | `sl auth status` | Show current user, key source, and API URL | @@ -327,6 +338,8 @@ api_key: sl_xxxxxxxxxxxxx api_base: https://sl.example.com # only for self-hosted instances # or for 1Password: op_ref: op://Personal/SimpleLogin API Key/credential +# or for password-store: +pass_ref: ps://Email/simplelogin ``` ## Automated Upstream Tracking diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index d1709c1..3c38225 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -14,6 +14,7 @@ The CLI supports three authentication methods: 1. Environment variables (SIMPLELOGIN_API_KEY or SL_API_KEY) 2. Direct API key stored in config file 3. 1Password integration via the op CLI + 4. Password-store integration via the pass CLI Use "sl auth login" to configure authentication, "sl auth status" to verify your current session, and "sl auth logout" to clear stored credentials.`, diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 4a7f1c7..201438d 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -17,7 +17,7 @@ var loginCmd = &cobra.Command{ Short: "Authenticate with SimpleLogin", Long: `Store your SimpleLogin API key for CLI access. -There are three ways to authenticate: +There are four ways to authenticate: 1. Direct API key: Provide your API key directly. It will be stored in @@ -25,10 +25,15 @@ There are three ways to authenticate: 2. 1Password integration: Store a reference to your API key in 1Password. The CLI will - use the "op" CLI to retrieve the key on each request. This is - the most secure option as the key is never stored on disk. + use the "op" CLI to retrieve the key on each request. The key + is never stored on disk. -3. Interactive: +3. Password-store integration: + Store a reference to your API key in password-store (pass). The CLI + will use the "pass" CLI to retrieve the key on each request. The key + is never stored on disk. + +4. Interactive: If no flags are provided, you will be prompted to enter your API key interactively. @@ -43,6 +48,9 @@ of your server (e.g. https://sl.example.com).`, # Login with 1Password integration sl auth login --1password --vault Personal --item "SimpleLogin API Key" + # Login with password-store integration + sl auth login --pass --item "Email/simplelogin" + # Login to a self-hosted instance sl auth login --key sl_xxxxxxxxxxxxx --url https://sl.example.com @@ -55,6 +63,7 @@ var ( loginKey string loginURL string login1Password bool + loginPass bool loginVault string loginItem string ) @@ -63,8 +72,9 @@ func init() { loginCmd.Flags().StringVar(&loginKey, "key", "", "API key to store (note: value will appear in shell history and ps output; prefer interactive or --1password)") loginCmd.Flags().StringVar(&loginURL, "url", "", "Base URL of a self-hosted SimpleLogin instance (e.g. https://sl.example.com)") loginCmd.Flags().BoolVar(&login1Password, "1password", false, "Use 1Password integration") + loginCmd.Flags().BoolVar(&loginPass, "pass", false, "Use password-store integration") loginCmd.Flags().StringVar(&loginVault, "vault", "", "1Password vault name") - loginCmd.Flags().StringVar(&loginItem, "item", "", "1Password item name") + loginCmd.Flags().StringVar(&loginItem, "item", "", "Credential store item name or path (1Password item name or password-store path)") } func runLogin(cmd *cobra.Command, args []string) error { @@ -75,6 +85,10 @@ func runLogin(cmd *cobra.Command, args []string) error { } } + if login1Password && loginPass { + return fmt.Errorf("--1password and --pass are mutually exclusive") + } + if login1Password { if loginVault == "" || loginItem == "" { return fmt.Errorf("--vault and --item are required with --1password") @@ -106,6 +120,40 @@ func runLogin(cmd *cobra.Command, args []string) error { return nil } + if loginPass { + if loginItem == "" { + return fmt.Errorf("--item is required with --pass") + } + if loginVault != "" { + return fmt.Errorf("--vault is only valid with --1password, not --pass") + } + + if err := intauth.SavePassRef(loginItem); err != nil { + return fmt.Errorf("failed to save password-store reference: %w", err) + } + + // Validate by trying to get the key and calling the API + key, err := intauth.GetAPIKey() + if err != nil { + output.PrintWarning("password-store reference saved, but could not validate: %v", err) + output.PrintWarning("Make sure the 'pass' CLI is installed and the entry exists.") + return nil + } + + client := api.NewClient(key, intauth.GetAPIBase()) + info, _, err := client.GetUserInfo() + if err != nil { + output.PrintWarning("password-store reference saved, but API validation failed: %v", err) + return nil + } + + output.PrintSuccess("Authenticated as %s (%s) via password-store", info.Name, info.Email) + if loginURL != "" { + output.PrintSuccess("Using custom API URL: %s", intauth.GetAPIBase()) + } + return nil + } + key := loginKey if key == "" { if !output.IsInteractive() { diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 060b193..188bcae 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -12,7 +12,7 @@ import ( var logoutCmd = &cobra.Command{ Use: "logout", Short: "Remove stored credentials", - Long: `Remove stored API key and 1Password references from the config file. + Long: `Remove stored API key, 1Password and password-store references from the config file. This command clears stored credentials from $XDG_CONFIG_HOME/simplelogin/config.yml (defaults to ~/.config/simplelogin/config.yml). It also attempts to invalidate the API session on the server. diff --git a/cmd/auth/status.go b/cmd/auth/status.go index 331434a..190e61f 100644 --- a/cmd/auth/status.go +++ b/cmd/auth/status.go @@ -18,7 +18,7 @@ var statusCmd = &cobra.Command{ user's name and email, and the source of the API key. This is useful to verify which account is active and how the CLI is -obtaining the API key (environment variable, 1Password, or config file).`, +obtaining the API key (environment variable, 1Password, password-store, or config file).`, Example: ` # Check authentication status sl auth status @@ -54,6 +54,8 @@ func runStatus(cmd *cobra.Command, args []string) error { source = "SL_API_KEY env var" } else if intauth.GetOPRef() != "" { source = "1Password (" + intauth.GetOPRef() + ")" + } else if intauth.GetPassRef() != "" { + source = "password-store (" + intauth.GetPassRef() + ")" } client := api.NewClient(key, intauth.GetAPIBase()) diff --git a/cmd/root.go b/cmd/root.go index 2e5f05d..5031597 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -35,7 +35,8 @@ Authentication: 1. SIMPLELOGIN_API_KEY environment variable 2. SL_API_KEY environment variable 3. 1Password CLI (if configured via sl auth login --1password) - 4. Config file ($XDG_CONFIG_HOME/simplelogin/config.yml, defaults to ~/.config/simplelogin/config.yml) + 4. Password-store CLI (if configured via sl auth login --pass) + 5. Config file ($XDG_CONFIG_HOME/simplelogin/config.yml, defaults to ~/.config/simplelogin/config.yml) Run "sl auth login" to get started. diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e048f15..8978854 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -16,6 +16,7 @@ type Config struct { APIKey string `yaml:"api_key"` APIBase string `yaml:"api_base"` OPRef string `yaml:"op_ref"` + PassRef string `yaml:"pass_ref"` } var configDir string @@ -77,7 +78,8 @@ func saveConfig(cfg Config) error { // 1. SIMPLELOGIN_API_KEY env var // 2. SL_API_KEY env var // 3. 1Password via op CLI (if op_ref is set in config) -// 4. api_key from config file +// 4. password-store via pass CLI (if pass_ref is set in config) +// 5. api_key from config file func GetAPIKey() (string, error) { // 1. SIMPLELOGIN_API_KEY env var if key := os.Getenv("SIMPLELOGIN_API_KEY"); key != "" { @@ -106,7 +108,22 @@ func GetAPIKey() (string, error) { } } - // 4. Config file api_key + // 4. password-store via pass CLI — password is on the first line of output + if cfg.PassRef != "" { + if passPath, err := exec.LookPath("pass"); err == nil && passPath != "" { + passArg := strings.TrimPrefix(cfg.PassRef, "ps://") + cmd := exec.Command("pass", "show", passArg) + out, err := cmd.Output() + if err == nil { + key := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]) + if key != "" { + return key, nil + } + } + } + } + + // 5. Config file api_key if cfg.APIKey != "" { return cfg.APIKey, nil } @@ -118,8 +135,9 @@ func GetAPIKey() (string, error) { func SaveAPIKey(key string) error { cfg := loadConfig() cfg.APIKey = key - // Remove op_ref if setting direct key + // Remove credential store refs if setting direct key cfg.OPRef = "" + cfg.PassRef = "" return saveConfig(cfg) } @@ -128,12 +146,30 @@ func SaveOPRef(vault, item string) error { ref := fmt.Sprintf("op://%s/%s/credential", vault, item) cfg := loadConfig() cfg.OPRef = ref - // Remove direct api_key if setting 1Password ref + // Remove other credential sources when setting 1Password ref cfg.APIKey = "" + cfg.PassRef = "" return saveConfig(cfg) } -// ClearConfig removes the API key and op_ref from the config file. +// SavePassRef stores the password-store path in the config file. +func SavePassRef(path string) error { + ref := "ps://" + path + cfg := loadConfig() + cfg.PassRef = ref + // Remove other credential sources when setting password-store ref + cfg.APIKey = "" + cfg.OPRef = "" + return saveConfig(cfg) +} + +// GetPassRef returns the stored password-store path, if any. +func GetPassRef() string { + cfg := loadConfig() + return cfg.PassRef +} + +// ClearConfig removes all stored credentials from the config file. func ClearConfig() error { if _, err := os.Stat(ConfigPath()); os.IsNotExist(err) { return nil @@ -141,6 +177,7 @@ func ClearConfig() error { cfg := loadConfig() cfg.APIKey = "" cfg.OPRef = "" + cfg.PassRef = "" return saveConfig(cfg) } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index e747ce2..998e1e0 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -51,6 +51,7 @@ func TestSaveAndLoadConfig(t *testing.T) { APIKey: "test-key-123", APIBase: "https://custom.example.com", OPRef: "op://vault/item/credential", + PassRef: "ps://Email/simplelogin", } if err := saveConfig(cfg); err != nil { t.Fatalf("saveConfig: %v", err) @@ -77,6 +78,9 @@ func TestSaveAndLoadConfig(t *testing.T) { if loaded.OPRef != cfg.OPRef { t.Errorf("OPRef = %q, want %q", loaded.OPRef, cfg.OPRef) } + if loaded.PassRef != cfg.PassRef { + t.Errorf("PassRef = %q, want %q", loaded.PassRef, cfg.PassRef) + } } func TestLoadConfig_NonExistent(t *testing.T) { @@ -84,7 +88,7 @@ func TestLoadConfig_NonExistent(t *testing.T) { setConfigDir(t, filepath.Join(dir, "nonexistent")) cfg := loadConfig() - if cfg.APIKey != "" || cfg.OPRef != "" || cfg.APIBase != "" { + if cfg.APIKey != "" || cfg.OPRef != "" || cfg.APIBase != "" || cfg.PassRef != "" { t.Errorf("expected empty Config for missing file, got: %+v", cfg) } } @@ -115,12 +119,12 @@ func TestSaveAPIKey(t *testing.T) { dir := t.TempDir() setConfigDir(t, dir) - // First set an op_ref - if err := saveConfig(Config{OPRef: "op://v/i/c"}); err != nil { + // First set both credential store refs + if err := saveConfig(Config{OPRef: "op://v/i/c", PassRef: "ps://Email/simplelogin"}); err != nil { t.Fatal(err) } - // SaveAPIKey should clear op_ref + // SaveAPIKey should clear both refs if err := SaveAPIKey("my-new-key"); err != nil { t.Fatalf("SaveAPIKey: %v", err) } @@ -132,14 +136,17 @@ func TestSaveAPIKey(t *testing.T) { if cfg.OPRef != "" { t.Errorf("OPRef should be cleared after SaveAPIKey, got %q", cfg.OPRef) } + if cfg.PassRef != "" { + t.Errorf("PassRef should be cleared after SaveAPIKey, got %q", cfg.PassRef) + } } func TestSaveOPRef(t *testing.T) { dir := t.TempDir() setConfigDir(t, dir) - // First set an api key - if err := saveConfig(Config{APIKey: "old-key"}); err != nil { + // First set an api key and a pass ref + if err := saveConfig(Config{APIKey: "old-key", PassRef: "ps://Email/simplelogin"}); err != nil { t.Fatal(err) } @@ -155,14 +162,17 @@ func TestSaveOPRef(t *testing.T) { if cfg.APIKey != "" { t.Errorf("APIKey should be cleared after SaveOPRef, got %q", cfg.APIKey) } + if cfg.PassRef != "" { + t.Errorf("PassRef should be cleared after SaveOPRef, got %q", cfg.PassRef) + } } func TestClearConfig(t *testing.T) { dir := t.TempDir() setConfigDir(t, dir) - // Set up a full config - if err := saveConfig(Config{APIKey: "key", OPRef: "ref"}); err != nil { + // Set up a full config including all credential sources + if err := saveConfig(Config{APIKey: "key", OPRef: "op://v/i/c", PassRef: "ps://Email/simplelogin"}); err != nil { t.Fatal(err) } @@ -171,7 +181,7 @@ func TestClearConfig(t *testing.T) { } cfg := loadConfig() - if cfg.APIKey != "" || cfg.OPRef != "" { + if cfg.APIKey != "" || cfg.OPRef != "" || cfg.PassRef != "" { t.Errorf("expected cleared config, got: %+v", cfg) } } @@ -363,6 +373,141 @@ func TestGetOPRef_Empty(t *testing.T) { } } +// --------------------------------------------------------------------------- +// SavePassRef / GetPassRef +// --------------------------------------------------------------------------- + +func TestSavePassRef(t *testing.T) { + dir := t.TempDir() + setConfigDir(t, dir) + + // First set an api key and an op_ref + if err := saveConfig(Config{APIKey: "old-key", OPRef: "op://v/i/c"}); err != nil { + t.Fatal(err) + } + + if err := SavePassRef("Email/simplelogin"); err != nil { + t.Fatalf("SavePassRef: %v", err) + } + + cfg := loadConfig() + expectedRef := "ps://Email/simplelogin" + if cfg.PassRef != expectedRef { + t.Errorf("PassRef = %q, want %q", cfg.PassRef, expectedRef) + } + if cfg.APIKey != "" { + t.Errorf("APIKey should be cleared after SavePassRef, got %q", cfg.APIKey) + } + if cfg.OPRef != "" { + t.Errorf("OPRef should be cleared after SavePassRef, got %q", cfg.OPRef) + } +} + +func TestGetPassRef(t *testing.T) { + dir := t.TempDir() + setConfigDir(t, dir) + + if err := saveConfig(Config{PassRef: "ps://Email/simplelogin"}); err != nil { + t.Fatal(err) + } + + got := GetPassRef() + if got != "ps://Email/simplelogin" { + t.Errorf("GetPassRef() = %q, want %q", got, "ps://Email/simplelogin") + } +} + +func TestGetPassRef_Empty(t *testing.T) { + dir := t.TempDir() + setConfigDir(t, filepath.Join(dir, "empty")) + + got := GetPassRef() + if got != "" { + t.Errorf("GetPassRef() for missing config = %q, want empty", got) + } +} + +// --------------------------------------------------------------------------- +// GetAPIKey — password-store branch +// --------------------------------------------------------------------------- + +func TestGetAPIKey_PasswordStore(t *testing.T) { + dir := t.TempDir() + setConfigDir(t, dir) + + // Create a stub 'pass' binary that outputs the key on line 1 and metadata on line 2 + stubDir := t.TempDir() + stubScript := filepath.Join(stubDir, "pass") + script := "#!/bin/sh\necho 'pass-store-key'\necho 'metadata line'\n" + if err := os.WriteFile(stubScript, []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + if err := SavePassRef("Email/simplelogin"); err != nil { + t.Fatal(err) + } + + t.Setenv("SIMPLELOGIN_API_KEY", "") + t.Setenv("SL_API_KEY", "") + + key, err := GetAPIKey() + if err != nil { + t.Fatalf("GetAPIKey: %v", err) + } + if key != "pass-store-key" { + t.Errorf("expected key from password-store, got %q", key) + } +} + +func TestGetAPIKey_PasswordStore_FirstLineOnly(t *testing.T) { + dir := t.TempDir() + setConfigDir(t, dir) + + // Stub outputs multiple lines; only the first should be used as the key + stubDir := t.TempDir() + stubScript := filepath.Join(stubDir, "pass") + script := "#!/bin/sh\necho 'actual-key'\necho 'URL: https://app.simplelogin.io'\necho 'Username: user@example.com'\n" + if err := os.WriteFile(stubScript, []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + if err := SavePassRef("Email/simplelogin"); err != nil { + t.Fatal(err) + } + + t.Setenv("SIMPLELOGIN_API_KEY", "") + t.Setenv("SL_API_KEY", "") + + key, err := GetAPIKey() + if err != nil { + t.Fatalf("GetAPIKey: %v", err) + } + if key != "actual-key" { + t.Errorf("expected only first line from pass output, got %q", key) + } +} + +func TestGetAPIKey_PasswordStore_BinaryNotFound(t *testing.T) { + dir := t.TempDir() + setConfigDir(t, dir) + + // Set pass_ref but point PATH at an empty dir so 'pass' can't be found + if err := saveConfig(Config{PassRef: "ps://Email/simplelogin"}); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", t.TempDir()) + t.Setenv("SIMPLELOGIN_API_KEY", "") + t.Setenv("SL_API_KEY", "") + + // Should fall through to api_key (empty), so error + _, err := GetAPIKey() + if err == nil { + t.Error("expected error when pass binary not found and no api_key configured") + } +} + // --------------------------------------------------------------------------- // Config directory creation // --------------------------------------------------------------------------- From 486f4897d9f658eb614b96a8bcb33ca4efb781c2 Mon Sep 17 00:00:00 2001 From: Alexis Hildebrandt Date: Fri, 5 Jun 2026 22:24:09 +0200 Subject: [PATCH 2/2] Update cmd/auth/auth.go Co-authored-by: Asier --- cmd/auth/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 3c38225..2a5611c 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -10,7 +10,7 @@ var Cmd = &cobra.Command{ Short: "Manage authentication", Long: `Manage authentication for the SimpleLogin CLI. -The CLI supports three authentication methods: +The CLI supports four authentication methods: 1. Environment variables (SIMPLELOGIN_API_KEY or SL_API_KEY) 2. Direct API key stored in config file 3. 1Password integration via the op CLI