Skip to content

Commit a4b2834

Browse files
authored
Add authentication APP-7384 #10
1 parent 5c6a6d6 commit a4b2834

8 files changed

Lines changed: 461 additions & 2 deletions

File tree

authconfig/authconfig.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package authconfig
2+
3+
import (
4+
"errors"
5+
"io/fs"
6+
"os"
7+
"path/filepath"
8+
"sync"
9+
10+
"gopkg.in/yaml.v3"
11+
)
12+
13+
// Config holds the persisted CLI configuration.
14+
type Config struct {
15+
APIKey string `yaml:"api_key"`
16+
}
17+
18+
// configDirFunc allows tests to override the config directory.
19+
var (
20+
configDirFunc = defaultDir
21+
configMu sync.RWMutex
22+
)
23+
24+
func defaultDir() (string, error) {
25+
home, err := os.UserHomeDir()
26+
if err != nil {
27+
return "", err
28+
}
29+
return filepath.Join(home, ".config", "dune"), nil
30+
}
31+
32+
// SetDirFunc overrides the config directory function (for testing).
33+
func SetDirFunc(fn func() (string, error)) {
34+
configMu.Lock()
35+
defer configMu.Unlock()
36+
configDirFunc = fn
37+
}
38+
39+
// ResetDirFunc restores the default config directory function.
40+
func ResetDirFunc() {
41+
configMu.Lock()
42+
defer configMu.Unlock()
43+
configDirFunc = defaultDir
44+
}
45+
46+
// Dir returns the config directory path ($HOME/.config/dune).
47+
func Dir() (string, error) {
48+
configMu.RLock()
49+
defer configMu.RUnlock()
50+
return configDirFunc()
51+
}
52+
53+
// Path returns the full path to the config file.
54+
func Path() (string, error) {
55+
dir, err := Dir()
56+
if err != nil {
57+
return "", err
58+
}
59+
return filepath.Join(dir, "config.yaml"), nil
60+
}
61+
62+
// Load reads and parses the config file. Returns nil, nil if the file does not exist.
63+
func Load() (*Config, error) {
64+
p, err := Path()
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
data, err := os.ReadFile(p)
70+
if err != nil {
71+
if errors.Is(err, fs.ErrNotExist) {
72+
return nil, nil
73+
}
74+
return nil, err
75+
}
76+
77+
var cfg Config
78+
if err := yaml.Unmarshal(data, &cfg); err != nil {
79+
return nil, err
80+
}
81+
return &cfg, nil
82+
}
83+
84+
// Save writes the config to disk, creating the directory (0700) and file (0600) as needed.
85+
func Save(cfg *Config) error {
86+
p, err := Path()
87+
if err != nil {
88+
return err
89+
}
90+
91+
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
92+
return err
93+
}
94+
95+
data, err := yaml.Marshal(cfg)
96+
if err != nil {
97+
return err
98+
}
99+
100+
return os.WriteFile(p, data, 0o600)
101+
}

authconfig/authconfig_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package authconfig_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/duneanalytics/cli/authconfig"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func setupTempDir(t *testing.T) string {
14+
t.Helper()
15+
dir := t.TempDir()
16+
authconfig.SetDirFunc(func() (string, error) { return dir, nil })
17+
t.Cleanup(authconfig.ResetDirFunc)
18+
return dir
19+
}
20+
21+
func TestSaveAndLoad(t *testing.T) {
22+
setupTempDir(t)
23+
24+
want := &authconfig.Config{APIKey: "dune_test_key_123"}
25+
require.NoError(t, authconfig.Save(want))
26+
27+
got, err := authconfig.Load()
28+
require.NoError(t, err)
29+
assert.Equal(t, want.APIKey, got.APIKey)
30+
}
31+
32+
func TestLoadNonExistent(t *testing.T) {
33+
setupTempDir(t)
34+
35+
cfg, err := authconfig.Load()
36+
assert.NoError(t, err)
37+
assert.Nil(t, cfg)
38+
}
39+
40+
func TestLoadMalformedYAML(t *testing.T) {
41+
dir := setupTempDir(t)
42+
43+
err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(":\tbad\nyaml{["), 0o600)
44+
require.NoError(t, err)
45+
46+
_, err = authconfig.Load()
47+
assert.Error(t, err)
48+
}
49+
50+
func TestSaveCreatesDir(t *testing.T) {
51+
tmp := t.TempDir()
52+
nested := filepath.Join(tmp, "sub", "dir")
53+
authconfig.SetDirFunc(func() (string, error) { return nested, nil })
54+
t.Cleanup(authconfig.ResetDirFunc)
55+
56+
require.NoError(t, authconfig.Save(&authconfig.Config{APIKey: "key"}))
57+
58+
info, err := os.Stat(nested)
59+
require.NoError(t, err)
60+
assert.True(t, info.IsDir())
61+
}
62+
63+
func TestFilePermissions(t *testing.T) {
64+
tmp := t.TempDir()
65+
dir := filepath.Join(tmp, "newdir")
66+
authconfig.SetDirFunc(func() (string, error) { return dir, nil })
67+
t.Cleanup(authconfig.ResetDirFunc)
68+
69+
require.NoError(t, authconfig.Save(&authconfig.Config{APIKey: "key"}))
70+
71+
dirInfo, err := os.Stat(dir)
72+
require.NoError(t, err)
73+
assert.Equal(t, os.FileMode(0o700), dirInfo.Mode().Perm())
74+
75+
fileInfo, err := os.Stat(filepath.Join(dir, "config.yaml"))
76+
require.NoError(t, err)
77+
assert.Equal(t, os.FileMode(0o600), fileInfo.Mode().Perm())
78+
}

cli/root.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"strings"
78

89
"github.com/charmbracelet/fang"
10+
"github.com/duneanalytics/cli/authconfig"
11+
"github.com/duneanalytics/cli/cmd/auth"
912
"github.com/duneanalytics/cli/cmd/execution"
1013
"github.com/duneanalytics/cli/cmd/query"
1114
"github.com/duneanalytics/cli/cmdutil"
@@ -22,6 +25,10 @@ var rootCmd = &cobra.Command{
2225
Long: "A command-line interface for interacting with the Dune Analytics API.\n" +
2326
"Manage queries, execute them, and retrieve results.",
2427
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
28+
if cmd.Annotations["skipAuth"] == "true" {
29+
return nil
30+
}
31+
2532
var env *config.Env
2633

2734
switch {
@@ -31,7 +38,19 @@ var rootCmd = &cobra.Command{
3138
var err error
3239
env, err = config.FromEnvVars()
3340
if err != nil {
34-
return fmt.Errorf("missing API key: set DUNE_API_KEY or pass --api-key")
41+
cfg, cfgErr := authconfig.Load()
42+
if cfgErr != nil {
43+
return fmt.Errorf("reading auth config: %w", cfgErr)
44+
}
45+
if cfg != nil {
46+
key := strings.TrimSpace(cfg.APIKey)
47+
if key == "" {
48+
return fmt.Errorf("empty API key in config: run dune auth --api-key <key>")
49+
}
50+
env = config.FromAPIKey(key)
51+
} else {
52+
return fmt.Errorf("missing API key: set DUNE_API_KEY, pass --api-key, or run dune auth")
53+
}
3554
}
3655
}
3756

@@ -43,6 +62,7 @@ var rootCmd = &cobra.Command{
4362

4463
func init() {
4564
rootCmd.PersistentFlags().StringVar(&apiKeyFlag, "api-key", "", "Dune API key (overrides DUNE_API_KEY env var)")
65+
rootCmd.AddCommand(auth.NewAuthCmd())
4666
rootCmd.AddCommand(query.NewQueryCmd())
4767
rootCmd.AddCommand(execution.NewExecutionCmd())
4868
}

cli/root_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/duneanalytics/cli/authconfig"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func setupRootTest(t *testing.T) string {
14+
t.Helper()
15+
dir := t.TempDir()
16+
authconfig.SetDirFunc(func() (string, error) { return dir, nil })
17+
t.Cleanup(authconfig.ResetDirFunc)
18+
prevAPIKey, hadAPIKey := os.LookupEnv("DUNE_API_KEY")
19+
require.NoError(t, os.Unsetenv("DUNE_API_KEY"))
20+
t.Cleanup(func() {
21+
if hadAPIKey {
22+
_ = os.Setenv("DUNE_API_KEY", prevAPIKey)
23+
return
24+
}
25+
_ = os.Unsetenv("DUNE_API_KEY")
26+
})
27+
apiKeyFlag = ""
28+
return dir
29+
}
30+
31+
func TestPersistentPreRunESkipAuth(t *testing.T) {
32+
setupRootTest(t)
33+
34+
cmd := rootCmd
35+
cmd.Annotations = map[string]string{"skipAuth": "true"}
36+
err := rootCmd.PersistentPreRunE(cmd, nil)
37+
require.NoError(t, err)
38+
}
39+
40+
func TestPersistentPreRunEMalformedConfig(t *testing.T) {
41+
dir := setupRootTest(t)
42+
err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(":\tbad\nyaml{["), 0o600)
43+
require.NoError(t, err)
44+
45+
cmd := rootCmd
46+
cmd.Annotations = nil
47+
err = rootCmd.PersistentPreRunE(cmd, nil)
48+
require.Error(t, err)
49+
assert.Contains(t, err.Error(), "reading auth config")
50+
}
51+
52+
func TestPersistentPreRunEEmptyConfig(t *testing.T) {
53+
setupRootTest(t)
54+
require.NoError(t, authconfig.Save(&authconfig.Config{APIKey: " "}))
55+
56+
cmd := rootCmd
57+
cmd.Annotations = nil
58+
err := rootCmd.PersistentPreRunE(cmd, nil)
59+
require.Error(t, err)
60+
assert.Contains(t, err.Error(), "empty API key in config")
61+
}
62+
63+
func TestPersistentPreRunEConfigFallback(t *testing.T) {
64+
setupRootTest(t)
65+
require.NoError(t, authconfig.Save(&authconfig.Config{APIKey: "saved_key"}))
66+
67+
cmd := rootCmd
68+
cmd.Annotations = nil
69+
err := rootCmd.PersistentPreRunE(cmd, nil)
70+
require.NoError(t, err)
71+
}

cmd/auth/auth.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package auth
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/duneanalytics/cli/authconfig"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
// NewAuthCmd returns the `auth` command.
14+
func NewAuthCmd() *cobra.Command {
15+
return &cobra.Command{
16+
Use: "auth",
17+
Short: "Authenticate with the Dune API",
18+
Long: "Save your Dune API key to ~/.config/dune/config.yaml so you don't need to pass it every time.",
19+
Annotations: map[string]string{"skipAuth": "true"},
20+
RunE: runAuth,
21+
}
22+
}
23+
24+
func runAuth(cmd *cobra.Command, _ []string) error {
25+
key, _ := cmd.Flags().GetString("api-key")
26+
27+
if key == "" {
28+
key = os.Getenv("DUNE_API_KEY")
29+
}
30+
31+
if key == "" {
32+
fmt.Fprint(cmd.ErrOrStderr(), "Enter your Dune API key: ")
33+
scanner := bufio.NewScanner(cmd.InOrStdin())
34+
if scanner.Scan() {
35+
key = strings.TrimSpace(scanner.Text())
36+
}
37+
}
38+
39+
if key == "" {
40+
return fmt.Errorf("no API key provided")
41+
}
42+
43+
if err := authconfig.Save(&authconfig.Config{APIKey: key}); err != nil {
44+
return fmt.Errorf("saving config: %w", err)
45+
}
46+
47+
p, _ := authconfig.Path()
48+
fmt.Fprintf(cmd.OutOrStdout(), "API key saved to %s\n", p)
49+
return nil
50+
}

0 commit comments

Comments
 (0)