Skip to content

Commit f7b4213

Browse files
authored
auth: add file-based credential storage (PRINFRA-123) (#17)
## Description ### What This Adds This PR adds **persistent local authentication** to the CLI. `heygen auth login` reads an API key from stdin (interactive prompt or pipe), stores it in the CLI config directory with **restrictive file permissions** (0600), and returns structured JSON. This gives the CLI a real local login flow instead of requiring users to export `HEYGEN_API_KEY` for every session. ### Login Flow `auth login` **skips the global pre-run auth check** via Cobra annotations (`skipAuth: "true"`). Before this change, `PersistentPreRunE` tried to resolve credentials for every command, which blocked the login command itself when no API key existed yet. ### Credential Resolution Order Credential lookup uses a chain resolver with priority order: 1. `HEYGEN_API_KEY` env var — for CI, Docker, and agent workflows 2. `~/.heygen/credentials` file — written by `heygen auth login` ### Error Handling The auth layer distinguishes between: - A credential source that is **absent** (`ErrNotConfigured`) — falls through to the next resolver - A credential source that is **present but broken** (permission denied, empty file) — surfaces immediately with an actionable hint pointing to the actual credential path ### No `--key` flag Industry standard (gh, AWS, Stripe, gcloud) is to never pass secrets as CLI flags — they leak into `ps` output and shell history. Instead: - **Agents/CI**: `HEYGEN_API_KEY` env var (no login needed) - **Humans (interactive)**: `heygen auth login` → prompt with hidden input - **Humans (scripted)**: `echo "$KEY" | heygen auth login` ### Example Outputs **Successful login:** ``` $ echo "$KEY" | heygen auth login { "message": "API key saved to ~/.heygen/credentials" } ``` **Empty input:** ``` $ echo "" | heygen auth login { "error": { "code": "usage_error", "message": "no API key provided" } } ``` **No credentials configured (on any API command):** ``` $ heygen video list { "error": { "code": "auth_error", "message": "no API key found", "hint": "Set HEYGEN_API_KEY env var or run: heygen auth login" } } ``` **Linear issue:** PRINFRA-123 ## Testing ### Automated (16 tests) - **Chain resolver** (4): first-wins, fallthrough on ErrNotConfigured, all-not-configured → auth error, broken-source → surfaces immediately - **File resolver** (4): reads key, missing file → ErrNotConfigured, empty file → real error, whitespace trimming - **File store** (4): save + read back, creates directory with 0700, file permissions 0600 (skipped on Windows), overwrite existing - **Auth login command** (4): success via stdin pipe, empty input → exit 2, overwrite, skipAuth bypass (no HEYGEN_API_KEY needed) - All existing M0 tests pass unchanged ### Manual smoke tests - `echo "key" | ./bin/heygen auth login` — exit 0, credentials saved with 0600 perms - `echo "" | ./bin/heygen auth login` — exit 2, `usage_error` - Overwrite: second key wins - Credential chain: `unset HEYGEN_API_KEY`, CLI resolves from credentials file - `heygen auth login --help` — shows `HEYGEN_API_KEY` env var hint for agents
1 parent 54faf6f commit f7b4213

20 files changed

Lines changed: 660 additions & 66 deletions

cmd/heygen/auth.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package main
2+
3+
import "github.com/spf13/cobra"
4+
5+
func newAuthCmd(ctx *cmdContext) *cobra.Command {
6+
cmd := &cobra.Command{
7+
Use: "auth",
8+
Short: "Manage authentication",
9+
Annotations: map[string]string{"skipAuth": "true"},
10+
}
11+
cmd.AddCommand(newAuthLoginCmd(ctx))
12+
return cmd
13+
}

cmd/heygen/auth_login.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/heygen-com/heygen-cli/internal/auth"
11+
clierrors "github.com/heygen-com/heygen-cli/internal/errors"
12+
"github.com/heygen-com/heygen-cli/internal/paths"
13+
"github.com/spf13/cobra"
14+
"golang.org/x/term"
15+
)
16+
17+
func newAuthLoginCmd(ctx *cmdContext) *cobra.Command {
18+
return &cobra.Command{
19+
Use: "login",
20+
Short: "Store API key for CLI authentication",
21+
Long: `Reads an API key from stdin and stores it for future CLI use.
22+
23+
Interactive:
24+
heygen auth login
25+
26+
Piped:
27+
echo "$KEY" | heygen auth login
28+
29+
You can also set the HEYGEN_API_KEY environment variable.
30+
The env var takes priority over stored credentials.`,
31+
RunE: func(cmd *cobra.Command, args []string) error {
32+
key, err := readAPIKey(cmd.InOrStdin(), cmd.ErrOrStderr())
33+
if err != nil {
34+
return err
35+
}
36+
if key == "" {
37+
return clierrors.NewUsage("no API key provided")
38+
}
39+
40+
store := &auth.FileCredentialStore{}
41+
if err := store.Save(key); err != nil {
42+
return clierrors.New(fmt.Sprintf("failed to save credentials: %v", err))
43+
}
44+
45+
credPath := filepath.Join(paths.ConfigDir(), "credentials")
46+
data, err := json.Marshal(map[string]string{
47+
"message": "API key saved to " + credPath,
48+
})
49+
if err != nil {
50+
return clierrors.New(fmt.Sprintf("failed to encode response: %v", err))
51+
}
52+
53+
return ctx.formatter.Data(data)
54+
},
55+
}
56+
}
57+
58+
func readAPIKey(in io.Reader, errOut io.Writer) (string, error) {
59+
if file, ok := in.(interface{ Fd() uintptr }); ok && term.IsTerminal(int(file.Fd())) {
60+
if _, err := fmt.Fprint(errOut, "Enter API key: "); err != nil {
61+
return "", clierrors.New(fmt.Sprintf("failed to write prompt: %v", err))
62+
}
63+
64+
raw, err := term.ReadPassword(int(file.Fd()))
65+
if _, writeErr := fmt.Fprintln(errOut); writeErr != nil && err == nil {
66+
err = writeErr
67+
}
68+
if err != nil {
69+
return "", clierrors.New(fmt.Sprintf("failed to read input: %v", err))
70+
}
71+
72+
return strings.TrimSpace(string(raw)), nil
73+
}
74+
75+
data, err := io.ReadAll(in)
76+
if err != nil {
77+
return "", clierrors.New(fmt.Sprintf("failed to read stdin: %v", err))
78+
}
79+
return strings.TrimSpace(string(data)), nil
80+
}

cmd/heygen/auth_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestAuthLogin_Success(t *testing.T) {
12+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
13+
14+
res := runCommandWithInput(t, "http://example.invalid", "", strings.NewReader("test-key-123\n"), "auth", "login")
15+
16+
if res.ExitCode != 0 {
17+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
18+
}
19+
20+
var parsed map[string]string
21+
if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil {
22+
t.Fatalf("stdout is not valid JSON: %v\nstdout: %s", err, res.Stdout)
23+
}
24+
if parsed["message"] == "" {
25+
t.Fatalf("expected success message, got %v", parsed)
26+
}
27+
28+
data, err := os.ReadFile(filepath.Join(os.Getenv("HEYGEN_CONFIG_DIR"), "credentials"))
29+
if err != nil {
30+
t.Fatalf("ReadFile: %v", err)
31+
}
32+
if string(data) != "test-key-123\n" {
33+
t.Fatalf("credentials = %q, want %q", string(data), "test-key-123\n")
34+
}
35+
}
36+
37+
func TestAuthLogin_EmptyInput(t *testing.T) {
38+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
39+
40+
res := runCommandWithInput(t, "http://example.invalid", "", strings.NewReader("\n"), "auth", "login")
41+
42+
if res.ExitCode != 2 {
43+
t.Fatalf("ExitCode = %d, want 2\nstderr: %s", res.ExitCode, res.Stderr)
44+
}
45+
}
46+
47+
func TestAuthLogin_OverwriteExisting(t *testing.T) {
48+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
49+
50+
first := runCommandWithInput(t, "http://example.invalid", "", strings.NewReader("first-key\n"), "auth", "login")
51+
if first.ExitCode != 0 {
52+
t.Fatalf("first login failed: %#v", first)
53+
}
54+
55+
second := runCommandWithInput(t, "http://example.invalid", "", strings.NewReader("second-key\n"), "auth", "login")
56+
if second.ExitCode != 0 {
57+
t.Fatalf("second login failed: %#v", second)
58+
}
59+
60+
data, err := os.ReadFile(filepath.Join(os.Getenv("HEYGEN_CONFIG_DIR"), "credentials"))
61+
if err != nil {
62+
t.Fatalf("ReadFile: %v", err)
63+
}
64+
if string(data) != "second-key\n" {
65+
t.Fatalf("credentials = %q, want %q", string(data), "second-key\n")
66+
}
67+
}
68+
69+
func TestAuthLogin_SkipsAuth(t *testing.T) {
70+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
71+
72+
res := runCommandWithInput(t, "http://example.invalid", "", strings.NewReader("test-key-123\n"), "auth", "login")
73+
74+
if res.ExitCode != 0 {
75+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
76+
}
77+
if res.Stderr != "" {
78+
t.Fatalf("stderr = %q, want empty", res.Stderr)
79+
}
80+
}

cmd/heygen/context.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package main
2+
3+
import (
4+
"github.com/heygen-com/heygen-cli/internal/auth"
5+
"github.com/heygen-com/heygen-cli/internal/client"
6+
"github.com/heygen-com/heygen-cli/internal/config"
7+
"github.com/heygen-com/heygen-cli/internal/output"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
// cmdContext holds shared dependencies created in PersistentPreRunE
12+
// and consumed by child commands via closures.
13+
type cmdContext struct {
14+
client *client.Client
15+
formatter output.Formatter
16+
configProvider config.Provider
17+
}
18+
19+
// skipAuth checks whether the command (or any parent) is annotated to
20+
// bypass credential resolution. Used by auth and config commands.
21+
func skipAuth(cmd *cobra.Command) bool {
22+
for c := cmd; c != nil; c = c.Parent() {
23+
if c.Annotations != nil && c.Annotations["skipAuth"] == "true" {
24+
return true
25+
}
26+
}
27+
return false
28+
}
29+
30+
// initContext sets up the config provider and, for commands that require
31+
// auth, resolves credentials and creates the HTTP client.
32+
func initContext(cmd *cobra.Command, version string, ctx *cmdContext) error {
33+
provider := &config.EnvProvider{}
34+
ctx.configProvider = provider
35+
36+
if skipAuth(cmd) {
37+
ctx.client = nil
38+
return nil
39+
}
40+
41+
resolver := &auth.ChainCredentialResolver{
42+
Resolvers: []auth.CredentialResolver{
43+
&auth.EnvCredentialResolver{},
44+
&auth.FileCredentialResolver{},
45+
},
46+
}
47+
apiKey, err := resolver.Resolve()
48+
if err != nil {
49+
return err
50+
}
51+
52+
ctx.client = client.New(apiKey,
53+
client.WithBaseURL(provider.BaseURL()),
54+
client.WithUserAgent("heygen-cli/"+version),
55+
)
56+
57+
return nil
58+
}

cmd/heygen/root.go

Lines changed: 4 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,12 @@ import (
55
"strings"
66

77
"github.com/heygen-com/heygen-cli/gen"
8-
"github.com/heygen-com/heygen-cli/internal/auth"
9-
"github.com/heygen-com/heygen-cli/internal/client"
108
"github.com/heygen-com/heygen-cli/internal/command"
11-
"github.com/heygen-com/heygen-cli/internal/config"
129
clierrors "github.com/heygen-com/heygen-cli/internal/errors"
1310
"github.com/heygen-com/heygen-cli/internal/output"
1411
"github.com/spf13/cobra"
1512
)
1613

17-
// cmdContext holds shared dependencies created in PersistentPreRunE
18-
// and consumed by child commands via closures.
19-
type cmdContext struct {
20-
client *client.Client
21-
formatter output.Formatter
22-
configProvider config.Provider
23-
}
24-
2514
func newRootCmd(version string, formatter output.Formatter) *cobra.Command {
2615
ctx := &cmdContext{formatter: formatter}
2716

@@ -32,24 +21,7 @@ func newRootCmd(version string, formatter output.Formatter) *cobra.Command {
3221
SilenceUsage: true, // we handle usage errors ourselves
3322
SilenceErrors: true, // we handle error output ourselves
3423
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
35-
// 1. Create config provider (BaseURL only — auth is Resolver's job)
36-
provider := &config.EnvProvider{}
37-
ctx.configProvider = provider
38-
39-
// 2. Resolve credentials (env var today; file-based storage later)
40-
resolver := &auth.EnvCredentialResolver{}
41-
apiKey, err := resolver.Resolve()
42-
if err != nil {
43-
return err
44-
}
45-
46-
// 3. Create client using config.Provider for BaseURL
47-
ctx.client = client.New(apiKey,
48-
client.WithBaseURL(provider.BaseURL()),
49-
client.WithUserAgent("heygen-cli/"+version),
50-
)
51-
52-
return nil
24+
return initContext(cmd, version, ctx)
5325
},
5426
}
5527

@@ -58,6 +30,7 @@ func newRootCmd(version string, formatter output.Formatter) *cobra.Command {
5830
return clierrors.NewUsage(err.Error())
5931
})
6032

33+
root.AddCommand(newAuthCmd(ctx))
6134
registerGroups(root, ctx, gen.Groups)
6235

6336
return root
@@ -76,28 +49,15 @@ func newRootCmdWithSpecs(version string, formatter output.Formatter, groups map[
7649
SilenceUsage: true,
7750
SilenceErrors: true,
7851
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
79-
provider := &config.EnvProvider{}
80-
ctx.configProvider = provider
81-
82-
resolver := &auth.EnvCredentialResolver{}
83-
apiKey, err := resolver.Resolve()
84-
if err != nil {
85-
return err
86-
}
87-
88-
ctx.client = client.New(apiKey,
89-
client.WithBaseURL(provider.BaseURL()),
90-
client.WithUserAgent("heygen-cli/"+version),
91-
)
92-
93-
return nil
52+
return initContext(cmd, version, ctx)
9453
},
9554
}
9655

9756
root.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
9857
return clierrors.NewUsage(err.Error())
9958
})
10059

60+
root.AddCommand(newAuthCmd(ctx))
10161
registerGroups(root, ctx, groups)
10262

10363
return root

cmd/heygen/testutil_test.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"bytes"
55
"errors"
6+
"io"
67
"net/http"
78
"net/http/httptest"
89
"testing"
@@ -60,18 +61,28 @@ func setupTestServer(t *testing.T, handlers map[string]testHandler) *httptest.Se
6061
// matches what production emits.
6162
func runCommand(t *testing.T, serverURL, apiKey string, args ...string) cmdResult {
6263
t.Helper()
64+
return runCommandWithInput(t, serverURL, apiKey, nil, args...)
65+
}
66+
67+
func runCommandWithInput(t *testing.T, serverURL, apiKey string, stdin io.Reader, args ...string) cmdResult {
68+
t.Helper()
6369

6470
var stdout, stderr bytes.Buffer
6571
formatter := output.NewJSONFormatter(&stdout, &stderr)
6672

6773
// Set env vars for this test
68-
t.Setenv("HEYGEN_API_KEY", apiKey)
74+
if apiKey != "" {
75+
t.Setenv("HEYGEN_API_KEY", apiKey)
76+
}
6977
t.Setenv("HEYGEN_API_BASE", serverURL)
7078

7179
cmd := newRootCmd("test", formatter)
7280
cmd.SetOut(&stdout)
7381
cmd.SetErr(&stderr)
7482
cmd.SetArgs(args)
83+
if stdin != nil {
84+
cmd.SetIn(stdin)
85+
}
7586

7687
err := cmd.Execute()
7788

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ require (
1010
gopkg.in/yaml.v3 v3.0.1
1111
)
1212

13+
require golang.org/x/term v0.30.0
14+
1315
require (
1416
github.com/go-openapi/jsonpointer v0.21.0 // indirect
1517
github.com/go-openapi/swag v0.23.0 // indirect
@@ -22,4 +24,5 @@ require (
2224
github.com/perimeterx/marshmallow v1.1.5 // indirect
2325
github.com/spf13/pflag v1.0.9 // indirect
2426
github.com/woodsbury/decimal128 v1.3.0 // indirect
27+
golang.org/x/sys v0.31.0 // indirect
2528
)

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95
4747
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
4848
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
4949
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
50+
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
51+
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
52+
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
53+
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
5054
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
5155
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
5256
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

0 commit comments

Comments
 (0)