Skip to content
Open
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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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
Expand Down
133 changes: 124 additions & 9 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package cli

import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"strings"

Expand All @@ -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
)

Expand All @@ -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")
}
Expand All @@ -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])
}
}
}
Comment on lines +130 to +177

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reimplements password input byte-by-byte to support the Ctrl+E peek, but it's a regression from term.ReadPassword: arrow keys send \x1b[D etc., the 0x1b is filtered (< 0x20) but [ and D are appended to the token. term.ReadPassword also handled Ctrl+U / Ctrl+W.

A --token-cmd 'rbw get x' flag would be simpler, scriptable, and avoid the raw-mode code entirely. The Ctrl+E shortcut can stay as sugar if you like, but I'd lead with the flag in the README.

}

// 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{
Expand Down Expand Up @@ -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")
}

Expand All @@ -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)
}
}

Expand Down
Loading