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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
66 changes: 59 additions & 7 deletions cmd/zeltapp/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import (
"bufio"
"errors"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"syscall"
"time"

"github.com/spf13/cobra"
"golang.org/x/term"
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -140,4 +193,3 @@ func whoamiCmd() *cobra.Command {
},
}
}

44 changes: 44 additions & 0 deletions cmd/zeltapp/auth_mfacommand_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
17 changes: 12 additions & 5 deletions cmd/zeltapp/keychain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <value>`, 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)))
}
Expand Down
38 changes: 38 additions & 0 deletions cmd/zeltapp/keychain_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}