diff --git a/README.md b/README.md index 87fbfd6..84df023 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,18 @@ Store tokens with `forge auth login`: forge auth login # interactive: asks domain + token forge auth login --domain github.com --token ghp_abc123 forge auth login --domain gitea.example.com --token abc123 --type gitea +forge auth login --domain github.com --token-cmd 'rbw get github-token' +``` + +`--token-cmd` stores a shell command instead of a literal token; the command +is run each time the token is needed (see [token commands](#token-commands) below). + +When prompted for a token interactively, press **Ctrl+E** as the first key +to enter a command instead: + +``` +Token for github.com (Ctrl+E first for command): +Command for token (e.g. rbw get github.com): rbw get github-token ``` Check what's configured with `forge auth status`. @@ -66,6 +78,38 @@ type = gitea token = abc123 ``` +### Token commands + +Token values can be replaced with a shell command prefixed by `!` (Unix only). +The command is executed via `sh -c` each time forge needs the token and its +stdout is used as the value. This lets you fetch secrets from a password manager +instead of storing them in plain text: + +```ini +[github.com] +token = !rbw get github-token + +[gitlab.com] +token = !pass show forge/gitlab + +[myhostedgitlab.example.com] +token = !rbw get --raw myhostedgitlab | jq -r '.fields | map(select(.name == "token"))[0].value' +``` + +The variable `FORGE_DOMAIN` is set to the domain name when the command runs, +so a single command can serve multiple domains: + +```ini +[github.com] +token = !pass show forge/$FORGE_DOMAIN + +[myhostedgitlab.example.com] +token = !pass show forge/$FORGE_DOMAIN +``` + +`forge auth login` sets this up interactively (Ctrl+E at the token prompt). +`forge auth status` shows the command source instead of the resolved value. + `.forge` in the repo root is for per-project settings, committed to the repo, no tokens: ```ini diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 0d3b78f..4b26a1a 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -2,7 +2,9 @@ package cli import ( "bufio" + "bytes" "fmt" + "io" "os" "strings" @@ -21,12 +23,14 @@ func init() { rootCmd.AddCommand(authCmd) authCmd.AddCommand(authLoginCmd()) authCmd.AddCommand(authStatusCmd()) + authCmd.AddCommand(authTokenCmd()) } func authLoginCmd() *cobra.Command { var ( domain string token string + tokenCmd string forgeType string ) @@ -49,17 +53,18 @@ func authLoginCmd() *cobra.Command { } } - if token == "" { + switch { + case tokenCmd != "": + token = "!" + tokenCmd + case token == "": if !interactive { - return fmt.Errorf("--token is required in non-interactive mode") + return fmt.Errorf("--token or --token-cmd is required in non-interactive mode") } - _, _ = fmt.Fprintf(os.Stderr, "Token for %s: ", domain) - raw, err := term.ReadPassword(int(os.Stdin.Fd())) - _, _ = fmt.Fprintln(os.Stderr) // newline after hidden input + var err error + token, err = readTokenInteractive(domain) if err != nil { return fmt.Errorf("reading token: %w", err) } - token = strings.TrimSpace(string(raw)) if token == "" { return fmt.Errorf("token cannot be empty") } @@ -76,9 +81,117 @@ func authLoginCmd() *cobra.Command { cmd.Flags().StringVar(&domain, "domain", "", "Forge domain (e.g. github.com, gitea.example.com)") cmd.Flags().StringVar(&token, "token", "", "API token") + cmd.Flags().StringVar(&tokenCmd, "token-cmd", "", "Shell command whose stdout is used as the token (Unix only)") cmd.Flags().StringVar(&forgeType, "type", "", "Forge type: github, gitlab, gitea, forgejo, bitbucket") + cmd.MarkFlagsMutuallyExclusive("token", "token-cmd") return cmd } + return cmd +} + +// readTokenInteractive prompts for a token in raw mode. +// Pressing Ctrl+E as the first key switches to command mode (stored as "!cmd"). +func readTokenInteractive(domain string) (string, error) { + const ctrlE = 0x05 + + fd := int(os.Stdin.Fd()) + _, _ = fmt.Fprintf(os.Stderr, "Token for %s (Ctrl+E first for command): ", domain) + + oldState, err := term.MakeRaw(fd) + if err != nil { + return "", fmt.Errorf("setting raw mode: %w", err) + } + + ch, err := readOneByte(os.Stdin) + if err != nil { + _ = term.Restore(fd, oldState) + _, _ = fmt.Fprintln(os.Stderr) + return "", err + } + + if ch == ctrlE { + _ = term.Restore(fd, oldState) + _, _ = fmt.Fprintln(os.Stderr) + return readCommandInteractive(domain) + } + + r := io.MultiReader(bytes.NewReader([]byte{ch}), os.Stdin) + return readRawToken(fd, oldState, r) +} + +func readOneByte(r io.Reader) (byte, error) { + b := make([]byte, 1) + _, err := r.Read(b) + return b[0], err +} + +// readRawToken accumulates a token character by character in raw mode. +// Always restores the terminal before returning. +func readRawToken(fd int, oldState *term.State, r io.Reader) (string, error) { + const ( + ctrlC = 0x03 + ctrlD = 0x04 + enter = 0x0D + newline = 0x0A + esc = 0x1B + backspace = 0x7F + del = 0x08 + printable = 0x20 + ) + defer func() { + _ = term.Restore(fd, oldState) + _, _ = fmt.Fprintln(os.Stderr) + }() + + var buf []byte + b := make([]byte, 1) + for { + if _, err := r.Read(b); err != nil { + return "", err + } + + switch b[0] { + case ctrlC, ctrlD: + return "", fmt.Errorf("interrupted") + case enter, newline: + return strings.TrimSpace(string(buf)), nil + case backspace, del: + if len(buf) > 0 { + buf = buf[:len(buf)-1] + } + case esc: + // Consume the rest of the escape sequence (e.g. arrow keys: \x1b[D). + for { + if _, err := r.Read(b); err != nil { + return "", err + } + if b[0] >= 'A' && b[0] <= '~' { + break + } + } + default: + if b[0] >= printable { + buf = append(buf, b[0]) + } + } + } +} + +// readCommandInteractive prompts the user to enter a shell command +// whose output will be used as the token at runtime. +// Returns the command prefixed with "!" for storage in the config. +func readCommandInteractive(domain string) (string, error) { + _, _ = fmt.Fprintf(os.Stderr, "Command for token (e.g. rbw get %s): ", domain) + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && line == "" { + return "", fmt.Errorf("reading command: %w", err) + } + cmd := strings.TrimSpace(line) + if cmd == "" { + return "", fmt.Errorf("command cannot be empty") + } + return "!" + cmd, nil +} func authStatusCmd() *cobra.Command { return &cobra.Command{ @@ -110,7 +223,9 @@ func authStatusCmd() *cobra.Command { if envToken != "" { sources = append(sources, "env") } - if cfgSection.Token != "" { + if cfgSection.TokenExec != "" { + sources = append(sources, fmt.Sprintf("config (cmd: %s)", cfgSection.TokenExec)) + } else if cfgSection.Token != "" { sources = append(sources, "config") } @@ -121,9 +236,9 @@ func authStatusCmd() *cobra.Command { forgeType := cfgSection.Type if forgeType != "" { - _, _ = fmt.Fprintf(os.Stdout, "%s (%s): %s\n", d, forgeType, status) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s (%s): %s\n", d, forgeType, status) } else { - _, _ = fmt.Fprintf(os.Stdout, "%s: %s\n", d, status) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", d, status) } } diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 150c5d8..4d0ba78 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -95,13 +95,57 @@ func TestAuthLoginNonInteractive(t *testing.T) { } } +func TestAuthLoginTokenCmd(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + config.ResetCache() + defer config.ResetCache() + + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{ + "auth", "login", + "--domain", "github.com", + "--token-cmd", "rbw get github-token", + }) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "forge", "config")) + if err != nil { + t.Fatalf("reading config: %v", err) + } + content := string(data) + if !strings.Contains(content, "token = !rbw get github-token") { + t.Errorf("expected token command in config, got:\n%s", content) + } +} + +func TestAuthLoginTokenAndTokenCmdMutuallyExclusive(t *testing.T) { + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{ + "auth", "login", + "--domain", "github.com", + "--token", "ghp_abc", + "--token-cmd", "rbw get github-token", + }) + + if err := rootCmd.Execute(); err == nil { + t.Fatal("expected error when both --token and --token-cmd are set") + } +} + func TestAuthStatus(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) config.ResetCache() defer config.ResetCache() - // Write a config with a domain cfgDir := filepath.Join(dir, "forge") _ = os.MkdirAll(cfgDir, 0700) _ = os.WriteFile(filepath.Join(cfgDir, "config"), []byte(`[gitea.example.com] @@ -114,8 +158,95 @@ token = some_token rootCmd.SetErr(&buf) rootCmd.SetArgs([]string{"auth", "status"}) - err := rootCmd.Execute() + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "gitea.example.com") { + t.Errorf("expected domain in output, got: %s", out) + } + if !strings.Contains(out, "token from config") { + t.Errorf("expected token source in output, got: %s", out) + } +} + +func TestAuthStatusWithTokenCmd(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + config.ResetCache() + defer config.ResetCache() + + cfgDir := filepath.Join(dir, "forge") + _ = os.MkdirAll(cfgDir, 0700) + _ = os.WriteFile(filepath.Join(cfgDir, "config"), []byte(`[gitlab.example.com] +type = gitlab +token = !echo secret +`), 0600) + + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs([]string{"auth", "status"}) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "cmd: echo secret") { + t.Errorf("expected command source in output, got: %s", out) + } +} + +func TestReadOneByte(t *testing.T) { + b, err := readOneByte(bytes.NewReader([]byte{'x'})) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if b != 'x' { + t.Errorf("expected 'x', got %q", b) + } +} + +func TestReadCommandInteractive(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin; _ = r.Close() }() + + _, _ = w.WriteString("rbw get github-token\n") + _ = w.Close() + + result, err := readCommandInteractive("github.com") if err != nil { t.Fatalf("unexpected error: %v", err) } + if result != "!rbw get github-token" { + t.Errorf("expected %q, got %q", "!rbw get github-token", result) + } +} + +func TestReadCommandInteractiveEmpty(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin; _ = r.Close() }() + + _, _ = w.WriteString("\n") + _ = w.Close() + + _, err = readCommandInteractive("github.com") + if err == nil { + t.Fatal("expected error for empty command") + } + if !strings.Contains(err.Error(), "cannot be empty") { + t.Errorf("expected empty command error, got: %v", err) + } } diff --git a/internal/config/config.go b/internal/config/config.go index d7b9085..e5b98fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -30,11 +31,21 @@ type DefaultSection struct { type DomainSection struct { Type string // github, gitlab, gitea, forgejo, bitbucket - Token string // only from user config, never .forge + Token string // resolved token value; only from user config, never .forge + TokenExec string // non-empty when token came from a "!cmd" reference (stores the raw value) SSHHost string // alternate host for git-over-ssh; the section name remains the API host GitProtocol string // https or ssh; overrides default } +// ResolveToken returns the token for this domain. If TokenExec is set, it +// executes the command and returns its output; otherwise it returns Token. +func (ds DomainSection) ResolveToken(domain string) (string, error) { + if ds.TokenExec != "" { + return execValue(ds.TokenExec, domain) + } + return ds.Token, nil +} + // DomainForSSHHost returns the API domain (the section name) whose ssh_host // matches the given host, or "" if none. Self-hosted GitLab in particular can // serve git-over-ssh on a different host than the web/API, so a remote URL like @@ -94,6 +105,28 @@ func parseGitProtocol(v string) (string, error) { } } +// execValue runs cmd via sh -c and returns its trimmed stdout. +// Shell features (pipes, quotes, substitutions) are supported. +// FORGE_DOMAIN is set to domain in the command environment. +// Stdin and stderr are wired to the terminal so interactive prompts +// (e.g. pinentry, rbw unlock) work and error output is visible directly. +func execValue(cmd, domain string) (string, error) { + cmd = strings.TrimSpace(cmd) + if cmd == "" { + return "", fmt.Errorf("empty command") + } + var stdout strings.Builder + c := exec.Command("sh", "-c", cmd) + c.Env = append(os.Environ(), "FORGE_DOMAIN="+domain) + c.Stdin = os.Stdin + c.Stdout = &stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + return "", fmt.Errorf("%q: %w", cmd, err) + } + return strings.TrimSpace(stdout.String()), nil +} + // ResetCache clears the cached config. Only useful in tests. func ResetCache() { once = sync.Once{} @@ -176,7 +209,11 @@ func loadFile(cfg *Config, path string, allowTokens bool) error { } if allowTokens { if v, ok := kv["token"]; ok { - ds.Token = v + if strings.HasPrefix(v, "!") { + ds.TokenExec = v[1:] + } else { + ds.Token = v + } } } cfg.Domains[name] = ds diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6e8a073..1ace421 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -483,6 +483,135 @@ token = old_token } } +func TestLoadFileTokenCommand(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + _ = os.WriteFile(path, []byte(`[github.com] +token = !echo mytoken +`), 0600) + + cfg := &Config{Domains: make(map[string]DomainSection)} + if err := loadFile(cfg, path, true); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ds := cfg.Domains["github.com"] + if ds.Token != "" { + t.Errorf("loadFile should not resolve token command, got Token=%q", ds.Token) + } + if ds.TokenExec != "echo mytoken" { + t.Errorf("expected TokenExec=%q, got %q", "echo mytoken", ds.TokenExec) + } + + resolved, err := ds.ResolveToken("github.com") + if err != nil { + t.Fatalf("ResolveToken: %v", err) + } + if resolved != "mytoken" { + t.Errorf("expected resolved token %q, got %q", "mytoken", resolved) + } +} + +func TestLoadFileTokenCommandForgeDomain(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + _ = os.WriteFile(path, []byte(`[gitlab.example.com] +token = !echo $FORGE_DOMAIN +`), 0600) + + cfg := &Config{Domains: make(map[string]DomainSection)} + if err := loadFile(cfg, path, true); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + resolved, err := cfg.Domains["gitlab.example.com"].ResolveToken("gitlab.example.com") + if err != nil { + t.Fatalf("ResolveToken: %v", err) + } + if resolved != "gitlab.example.com" { + t.Errorf("expected FORGE_DOMAIN=gitlab.example.com, got %q", resolved) + } +} + +func TestLoadFileTokenCommandFails(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + _ = os.WriteFile(path, []byte(`[github.com] +token = !false +`), 0600) + + cfg := &Config{Domains: make(map[string]DomainSection)} + if err := loadFile(cfg, path, true); err != nil { + t.Fatalf("loadFile should not fail on bad command, got: %v", err) + } + + _, err := cfg.Domains["github.com"].ResolveToken("github.com") + if err == nil { + t.Fatal("expected error from failing command, got nil") + } +} + +func TestLoadFileTokenCommandMissingBinary(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + _ = os.WriteFile(path, []byte(`[github.com] +token = !no-such-binary-xyz +`), 0600) + + cfg := &Config{Domains: make(map[string]DomainSection)} + if err := loadFile(cfg, path, true); err != nil { + t.Fatalf("loadFile should not fail on missing binary, got: %v", err) + } + + _, err := cfg.Domains["github.com"].ResolveToken("github.com") + if err == nil { + t.Fatal("expected error for missing binary, got nil") + } +} + +func TestLoadFileTokenCommandNotExecutedInProjectConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".forge") + _ = os.WriteFile(path, []byte(`[github.com] +token = !echo secret +`), 0644) + + cfg := &Config{Domains: make(map[string]DomainSection)} + // allowTokens=false: command must not be executed, token must stay empty + if err := loadFile(cfg, path, false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ds := cfg.Domains["github.com"] + if ds.Token != "" { + t.Errorf("project config should not resolve token commands, got %q", ds.Token) + } + if ds.TokenExec != "" { + t.Errorf("project config should not set TokenExec, got %q", ds.TokenExec) + } +} + +func TestLoadFileLiteralTokenUnchanged(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + _ = os.WriteFile(path, []byte(`[github.com] +token = ghp_literal +`), 0600) + + cfg := &Config{Domains: make(map[string]DomainSection)} + if err := loadFile(cfg, path, true); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ds := cfg.Domains["github.com"] + if ds.Token != "ghp_literal" { + t.Errorf("expected literal token, got %q", ds.Token) + } + if ds.TokenExec != "" { + t.Errorf("expected empty TokenExec for literal token, got %q", ds.TokenExec) + } +} + func TestGitProtocolFor(t *testing.T) { ResetCache() defer ResetCache() diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index 93c7191..e55dcc7 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -291,7 +291,8 @@ func TokenForDomain(domain string) string { if err != nil || cfg == nil { return "" } - return cfg.Domains[domain].Token + token, _ := cfg.Domains[domain].ResolveToken(domain) + return token } // TokenForDomainEnv looks up a token from environment variables only.