diff --git a/README.md b/README.md index b7dc935..5503cc2 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,18 @@ zeltapp auth whoami zeltapp auth logout [--forget] # --forget also wipes the Keychain entry ``` +Non-interactive login (headless / scripted). Default is unchanged — these two +flags are opt-in: + +``` +your-password-source | \ + zeltapp auth login -e me@example.com --password-stdin \ + --mfa-command 'my-fetch-code.sh' +``` + +- `--password-stdin` reads the password from the first line of stdin instead of a TTY prompt. +- `--mfa-command` runs a shell command (via `sh -c`) *after* the code is sent; the first 6-digit run in its stdout is used, so surrounding text is fine. The command is exported `ZELT_MFA_METHOD` and `ZELT_MFA_SINCE` (unix seconds, sampled just before the send) so it can ignore a stale code from a prior attempt. + ### people ``` diff --git a/cmd/zeltapp/auth.go b/cmd/zeltapp/auth.go index 32de1e2..acb29d5 100644 --- a/cmd/zeltapp/auth.go +++ b/cmd/zeltapp/auth.go @@ -4,8 +4,12 @@ import ( "bufio" "errors" "fmt" + "os" + "os/exec" + "regexp" "strings" "syscall" + "time" "github.com/spf13/cobra" "golang.org/x/term" @@ -23,9 +27,21 @@ func authCmd() *cobra.Command { func loginCmd() *cobra.Command { var email string var remember bool + var passwordStdin bool + var mfaCommand string cmd := &cobra.Command{ Use: "login", Short: "Authenticate (prompts for email, password, MFA code)", + Long: "Authenticate against Zelt.\n\n" + + "Interactive by default (prompts for email, password, MFA code). For headless\n" + + "or scripted use, pass --password-stdin to read the password from stdin and\n" + + "--mfa-command to fetch the emailed MFA code without a prompt:\n\n" + + " your-password-source | \\\n" + + " zeltapp auth login -e me@example.com --password-stdin \\\n" + + " --mfa-command 'fetch-code.sh'\n\n" + + "--mfa-command runs after the code is sent; its stdout must contain the\n" + + "6-digit code (any surrounding text is ignored). ZELT_MFA_METHOD and\n" + + "ZELT_MFA_SINCE (unix seconds, set just before the send) are exported to it.", RunE: func(cmd *cobra.Command, args []string) error { c, err := newClientFromFlags(flagVerbose) if err != nil { @@ -45,18 +61,30 @@ func loginCmd() *cobra.Command { return errors.New("email required") } - fmt.Fprint(stderr, "password: ") - pwBytes, err := term.ReadPassword(int(syscall.Stdin)) - fmt.Fprintln(stderr) - if err != nil { - return err + var password string + if passwordStdin { + line, err := reader.ReadString('\n') + if err != nil && line == "" { + return fmt.Errorf("reading password from stdin: %w", err) + } + password = strings.TrimRight(line, "\r\n") + } else { + fmt.Fprint(stderr, "password: ") + pwBytes, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Fprintln(stderr) + if err != nil { + return err + } + password = string(pwBytes) } - password := string(pwBytes) if password == "" { return errors.New("password required") } prompt := func(method string) (string, error) { + if mfaCommand != "" { + return runMFACommand(mfaCommand, method) + } fmt.Fprintf(stderr, "MFA code (%s): ", method) line, err := reader.ReadString('\n') return strings.TrimSpace(line), err @@ -84,9 +112,34 @@ func loginCmd() *cobra.Command { } cmd.Flags().StringVarP(&email, "email", "e", "", "email (otherwise prompted)") cmd.Flags().BoolVar(&remember, "remember", true, "save password in macOS Keychain (pass --remember=false to opt out)") + cmd.Flags().BoolVar(&passwordStdin, "password-stdin", false, "read password from stdin instead of a TTY prompt") + cmd.Flags().StringVar(&mfaCommand, "mfa-command", "", "shell command whose stdout yields the MFA code (no TTY prompt)") return cmd } +// runMFACommand runs the --mfa-command via `sh -c` and extracts a 6-digit code +// from its stdout. It runs after the code has been sent, so a command that +// polls email finds a fresh one. ZELT_MFA_METHOD and ZELT_MFA_SINCE (unix +// seconds, sampled just before this call) are exported so the command can +// ignore codes minted before this login attempt. +func runMFACommand(command, method string) (string, error) { + c := exec.Command("sh", "-c", command) + c.Env = append(os.Environ(), + "ZELT_MFA_METHOD="+method, + fmt.Sprintf("ZELT_MFA_SINCE=%d", time.Now().Unix()), + ) + c.Stderr = stderr + out, err := c.Output() + if err != nil { + return "", fmt.Errorf("mfa-command failed: %w", err) + } + m := regexp.MustCompile(`\d{6}`).FindString(string(out)) + if m == "" { + return "", errors.New("mfa-command produced no 6-digit code") + } + return m, nil +} + func logoutCmd() *cobra.Command { var forget bool cmd := &cobra.Command{ @@ -140,4 +193,3 @@ func whoamiCmd() *cobra.Command { }, } } - diff --git a/cmd/zeltapp/auth_mfacommand_test.go b/cmd/zeltapp/auth_mfacommand_test.go new file mode 100644 index 0000000..6712296 --- /dev/null +++ b/cmd/zeltapp/auth_mfacommand_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "strings" + "testing" +) + +func TestRunMFACommand_ExtractsCode(t *testing.T) { + // stdout with surrounding noise - only the 6-digit code should be taken. + code, err := runMFACommand("echo 'your code is 123456 thanks'", "email") + if err != nil { + t.Fatal(err) + } + if code != "123456" { + t.Errorf("want 123456, got %q", code) + } +} + +func TestRunMFACommand_ExportsEnv(t *testing.T) { + // The command can see ZELT_MFA_METHOD and ZELT_MFA_SINCE (POSIX sh - the + // command runs via `sh -c`, which is dash on Linux CI). It only emits the + // code when both are set. + code, err := runMFACommand(`[ -n "$ZELT_MFA_METHOD" ] && [ -n "$ZELT_MFA_SINCE" ] && echo 654321`, "email") + if err != nil { + t.Fatal(err) + } + if code != "654321" { + t.Errorf("want 654321, got %q", code) + } +} + +func TestRunMFACommand_NoCode(t *testing.T) { + _, err := runMFACommand("echo nothing here", "email") + if err == nil || !strings.Contains(err.Error(), "no 6-digit code") { + t.Fatalf("want no-code error, got %v", err) + } +} + +func TestRunMFACommand_CommandFails(t *testing.T) { + _, err := runMFACommand("exit 3", "email") + if err == nil || !strings.Contains(err.Error(), "mfa-command failed") { + t.Fatalf("want failure error, got %v", err) + } +} diff --git a/cmd/zeltapp/keychain.go b/cmd/zeltapp/keychain.go index 4b18a1b..d15794b 100644 --- a/cmd/zeltapp/keychain.go +++ b/cmd/zeltapp/keychain.go @@ -37,12 +37,19 @@ func (s *fileStore) SetPassword(email, password string) error { } // Pipe the password via stdin rather than argv so it never appears in // `ps` output (review #4). `security add-generic-password -w` with no - // inline value reads the password from a prompt; we redirect that prompt - // to stdin and provide the bytes ourselves. `-U` updates if the entry - // already exists. + // inline value prompts for the password AND a retype confirmation, so we + // must feed the value twice - sending it once stored a truncated/empty + // value or failed with "passwords don't match". + // + // We also delete first: `-U` (update) is silently ignored when the value + // comes from stdin rather than an inline `-w `, so `add ... -U` + // errors "already exists" on every re-login once an entry is present. + // Delete-then-add is the only idempotent path that keeps the password out + // of argv. + _ = s.DeletePassword(email) // ignore "not found" cmd := exec.Command(s.keychainCmd, "add-generic-password", - "-a", email, "-s", keychainService, "-w", "-U") - cmd.Stdin = strings.NewReader(password + "\n") + "-a", email, "-s", keychainService, "-w") + cmd.Stdin = strings.NewReader(password + "\n" + password + "\n") if out, err := cmd.CombinedOutput(); err != nil { return errors.New("keychain set: " + strings.TrimSpace(string(out))) } diff --git a/cmd/zeltapp/keychain_test.go b/cmd/zeltapp/keychain_test.go new file mode 100644 index 0000000..e82004e --- /dev/null +++ b/cmd/zeltapp/keychain_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "os" + "testing" +) + +// Exercises the real macOS `security` roundtrip: set, read, set again +// (idempotency), read, delete. Uses a throwaway account so it never touches the +// real zeltapp-cli entry. Skips where /usr/bin/security is absent (e.g. Linux CI). +func TestKeychain_SetGetIdempotent(t *testing.T) { + const bin = "/usr/bin/security" + if info, err := os.Stat(bin); err != nil || info.Mode()&0o111 == 0 { + t.Skip("no macOS security binary") + } + s := &fileStore{keychainCmd: bin} + email := "zeltapp-cli-test-" + t.Name() + "@example.com" + t.Cleanup(func() { _ = s.DeletePassword(email) }) + + // Password with a space and symbols - the kind that broke single-line input. + pw1 := "p@ss w0rd!#1" + if err := s.SetPassword(email, pw1); err != nil { + t.Fatalf("first set: %v", err) + } + if got, err := s.GetPassword(email); err != nil || got != pw1 { + t.Fatalf("get after first set: got %q err %v", got, err) + } + + // Second set on an existing entry must succeed and overwrite (this is the + // path that used to fail with "already exists"). + pw2 := "different-2" + if err := s.SetPassword(email, pw2); err != nil { + t.Fatalf("second set (idempotency): %v", err) + } + if got, err := s.GetPassword(email); err != nil || got != pw2 { + t.Fatalf("get after second set: got %q err %v", got, err) + } +}