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

Instead of a literal `token`, use `token-cmd` to specify a shell command (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-cmd = rbw get github-token

[gitlab.com]
token-cmd = pass show forge/gitlab

[myhostedgitlab.example.com]
token-cmd = 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-cmd = pass show forge/$FORGE_DOMAIN

[myhostedgitlab.example.com]
token-cmd = 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
142 changes: 130 additions & 12 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 @@ -27,6 +29,7 @@ func authLoginCmd() *cobra.Command {
var (
domain string
token string
tokenCmd string
forgeType string
)

Expand All @@ -49,37 +52,150 @@ func authLoginCmd() *cobra.Command {
}
}

if token == "" {
switch {
case tokenCmd != "":
// tokenCmd is already set from --token-cmd flag
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, tokenCmd, err = readTokenInteractive(domain)
if err != nil {
return fmt.Errorf("reading token: %w", err)
}
token = strings.TrimSpace(string(raw))
if token == "" {
if token == "" && tokenCmd == "" {
return fmt.Errorf("token cannot be empty")
}
}

if err := config.SetDomain(domain, token, forgeType); err != nil {
if err := config.SetDomain(domain, token, tokenCmd, forgeType); err != nil {
return fmt.Errorf("saving config: %w", err)
}

_, _ = fmt.Fprintf(os.Stderr, "Stored credentials for %s\n", domain)
if tokenCmd != "" {
_, _ = fmt.Fprintf(os.Stderr, "Stored token command for %s\n", domain)
} else {
_, _ = fmt.Fprintf(os.Stderr, "Stored credentials for %s\n", domain)
}
return nil
},
}

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
}

// readTokenInteractive prompts for a token in raw mode.
// Pressing Ctrl+E as the first key switches to command mode.
// Exactly one of token or tokenCmd is non-empty on success.
func readTokenInteractive(domain string) (token, tokenCmd string, err 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)
cmd, err := readCommandInteractive(domain)
return "", cmd, err
}

r := io.MultiReader(bytes.NewReader([]byte{ch}), os.Stdin)
tok, err := readRawToken(fd, oldState, r)
return tok, "", err
}

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 thread
pigam marked this conversation as resolved.
}

// readCommandInteractive prompts the user to enter a shell command
// whose output will be used as the token at runtime.
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{
Use: "status",
Expand Down Expand Up @@ -110,7 +226,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 +239,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