diff --git a/README.md b/README.md index f804f88..713ec80 100644 --- a/README.md +++ b/README.md @@ -34,15 +34,36 @@ chmod +x aperiodic ## Authentication -Set your API key as an environment variable: +Sign in once per machine: ```bash -export APERIODIC_API_KEY=your_api_key +aperiodic login ``` -Get your API key at [aperiodic.io](https://aperiodic.io). +It opens your browser, or, over SSH or on a machine without a display, prints a link to open on any device. Check the code there matches the one in your terminal and allow it. A free account works: it covers every metric for 2025 on BTC, ETH, SOL and 10 RWA perps. -For [preview data](#preview-data) (`--preview`), no API key is required — the CLI uses the shared public demo key automatically. +The machine gets a key of its own, named after it and saved in `~/.config/aperiodic/credentials` (readable only by you; `%APPDATA%\aperiodic` on Windows, or `$APERIODIC_CONFIG_DIR`). The Python client reads the same file. See and revoke each machine's key at [aperiodic.io/home/api](https://aperiodic.io/home/api). + +```bash +aperiodic whoami # which key is in use, and what the plan covers +aperiodic logout # revoke this machine's key and remove it +``` + +Running `aperiodic login` again keeps a saved key that still works. `aperiodic login --force` replaces it with a new one and revokes the old one, so keys don't pile up on your account. If a saved key was revoked, every command says so and asks you to sign in again. + +For scheduled jobs, CI and containers, set a key instead. Make a named key for the job at [aperiodic.io/home/api](https://aperiodic.io/home/api): + +```bash +export APERIODIC_API_KEY=apk_… +``` + +Every command picks its key in this order: + +1. `APERIODIC_API_KEY`, when it is set; +2. the shared public demo key, for a `--preview` request; +3. the key `aperiodic login` saved. + +So [preview data](#preview-data) (`--preview`) needs no API key, and a saved key never takes over a preview request: even a revoked one can't break the free sample. The Python client uses the same order. [Raw data](#raw-data) needs a key on the Prime + Raw plan; `aperiodic raw coverage` and `aperiodic raw --preview` work without one. diff --git a/cli.go b/cli.go index ee5da1a..99db32d 100644 --- a/cli.go +++ b/cli.go @@ -4,23 +4,49 @@ import ( "flag" "fmt" "io" + "net/http" "os" + "time" ) type CLI struct { Stdout io.Writer Stderr io.Writer Env func(string) string + // OpenURL opens a link in the browser; login falls back to printing it. + OpenURL func(string) error + + // client, sleepFunc and nowFunc let the package's tests drive login + // without waiting; nil means the real ones. + client *http.Client + sleepFunc func(time.Duration) + nowFunc func() time.Time } func NewCLI() *CLI { return &CLI{ - Stdout: os.Stdout, - Stderr: os.Stderr, - Env: os.Getenv, + Stdout: os.Stdout, + Stderr: os.Stderr, + Env: os.Getenv, + OpenURL: openBrowser, } } +func (c *CLI) sleep(d time.Duration) { + if c.sleepFunc != nil { + c.sleepFunc(d) + return + } + time.Sleep(d) +} + +func (c *CLI) now() time.Time { + if c.nowFunc != nil { + return c.nowFunc() + } + return time.Now() +} + func (c *CLI) Run(args []string) int { if len(args) == 0 { c.printUsage() @@ -33,6 +59,15 @@ func (c *CLI) Run(args []string) int { return 0 } + switch cmd { + case "login": + return c.runLogin(args[1:]) + case "logout": + return c.runLogout(args[1:]) + case "whoami": + return c.runWhoami(args[1:]) + } + if cmd == "raw" { return c.runRaw(args[1:]) } @@ -57,7 +92,7 @@ func (c *CLI) Run(args []string) int { return 2 } - apiKey, ok := c.resolveAPIKey(*previewFlag) + apiKey, source, ok := c.resolveAPIKey(*previewFlag) if !ok { return 1 } @@ -65,7 +100,7 @@ func (c *CLI) Run(args []string) int { client := NewAperiodicClient(apiKey) if cmd == "symbols" { - return c.handleSymbols(client, *exchangeFlag) + return c.handleSymbols(client, *exchangeFlag, source) } if *outputDirFlag == "" { @@ -73,31 +108,36 @@ func (c *CLI) Run(args []string) int { return 1 } - return c.handleData(client, cmd, *timestampFlag, *intervalFlag, *exchangeFlag, *symbolFlag, *startDateFlag, *endDateFlag, *maxConcurrentFlag, *outputDirFlag, *previewFlag) + return c.handleData(client, source, cmd, *timestampFlag, *intervalFlag, *exchangeFlag, *symbolFlag, *startDateFlag, *endDateFlag, *maxConcurrentFlag, *outputDirFlag, *previewFlag) } func isHelpArg(arg string) bool { return arg == "help" || arg == "-h" || arg == "--help" } -// resolveAPIKey returns APERIODIC_API_KEY or, for a preview request without -// one, the shared demo key. It reports the missing key itself. -func (c *CLI) resolveAPIKey(preview bool) (string, bool) { +// resolveAPIKey returns APERIODIC_API_KEY, else, for a preview request, the +// shared demo key, else the key `aperiodic login` saved, with where it came +// from. A saved key never answers a preview request, so a revoked one cannot +// break the free sample. It reports the missing key itself. +func (c *CLI) resolveAPIKey(preview bool) (apiKey, source string, ok bool) { if apiKey := c.Env("APERIODIC_API_KEY"); apiKey != "" { - return apiKey, true + return apiKey, "APERIODIC_API_KEY", true } if preview { - // Preview data is served against the shared demo key, so no key is required. - return DemoAPIKey, true + return DemoAPIKey, "the shared demo key", true + } + if apiKey, source, ok := c.findAPIKey(); ok { + return apiKey, source, true } - fmt.Fprintln(c.Stderr, "Error: APERIODIC_API_KEY environment variable not set (pass --preview to use the shared demo key)") - return "", false + fmt.Fprintln(c.Stderr, "Error: no API key. Run 'aperiodic login', set APERIODIC_API_KEY, or pass --preview for the free sample.") + return "", "", false } func (c *CLI) printUsage() { fmt.Fprintln(c.Stdout, "Aperiodic CLI Client") fmt.Fprintln(c.Stdout) fmt.Fprintln(c.Stdout, "Usage:") + fmt.Fprintln(c.Stdout, " aperiodic login [--no-browser] [--name NAME] [--force]") fmt.Fprintln(c.Stdout, " aperiodic [flags]") fmt.Fprintln(c.Stdout, " aperiodic symbols [flags]") fmt.Fprintln(c.Stdout, " aperiodic raw [flags]") @@ -126,13 +166,16 @@ func (c *CLI) printUsage() { fmt.Fprintln(c.Stdout, " derivative_price Derivative price") fmt.Fprintln(c.Stdout) fmt.Fprintln(c.Stdout, "Commands:") + fmt.Fprintln(c.Stdout, " login Sign in and save a key for this machine (works over SSH too)") + fmt.Fprintln(c.Stdout, " logout Revoke this machine's key and remove it") + fmt.Fprintln(c.Stdout, " whoami Show which key is in use and what the plan covers") fmt.Fprintln(c.Stdout, " symbols List available symbols for an exchange") fmt.Fprintln(c.Stdout, " raw Download raw trades, quotes and derivative ticks (Prime + Raw plan; see 'aperiodic raw help')") fmt.Fprintln(c.Stdout, " stream Stream live rows over WebSocket as JSON lines (see 'aperiodic stream help')") fmt.Fprintln(c.Stdout, " help Show this help") fmt.Fprintln(c.Stdout) fmt.Fprintln(c.Stdout, "Environment:") - fmt.Fprintln(c.Stdout, " APERIODIC_API_KEY Aperiodic API key (required, except with --preview)") + fmt.Fprintln(c.Stdout, " APERIODIC_API_KEY Aperiodic API key; used before the key 'aperiodic login' saved") fmt.Fprintln(c.Stdout) fmt.Fprintln(c.Stdout, "Flags:") fmt.Fprintln(c.Stdout, " -end-date string") @@ -155,10 +198,12 @@ func (c *CLI) printUsage() { fmt.Fprintln(c.Stdout, " Timestamp source (exchange, true) (default \"exchange\")") } -func (c *CLI) handleSymbols(client *AperiodicClient, exchange string) int { +func (c *CLI) handleSymbols(client *AperiodicClient, exchange, keySource string) int { symbols, err := client.GetSymbols(exchange) if err != nil { - fmt.Fprintf(c.Stderr, "Error fetching symbols: %v\n", err) + if !c.reportRejectedKey(statusOf(err), keySource) { + fmt.Fprintf(c.Stderr, "Error fetching symbols: %v\n", err) + } return 1 } @@ -169,7 +214,7 @@ func (c *CLI) handleSymbols(client *AperiodicClient, exchange string) int { return 0 } -func (c *CLI) handleData(client *AperiodicClient, metric, timestamp, interval, exchange, symbol, startDate, endDate string, maxConcurrent int, outputDir string, preview bool) int { +func (c *CLI) handleData(client *AperiodicClient, keySource, metric, timestamp, interval, exchange, symbol, startDate, endDate string, maxConcurrent int, outputDir string, preview bool) int { if symbol == "" { fmt.Fprintln(c.Stderr, "Error: --symbol is required") return 1 @@ -181,7 +226,9 @@ func (c *CLI) handleData(client *AperiodicClient, metric, timestamp, interval, e resp, err := client.FetchPresignedUrls(metric, TimestampType(timestamp), Interval(interval), exchange, symbol, startDate, endDate, preview) if err != nil { - fmt.Fprintf(c.Stderr, "Error fetching file URLs: %v\n", err) + if !c.reportRejectedKey(statusOf(err), keySource) { + fmt.Fprintf(c.Stderr, "Error fetching file URLs: %v\n", err) + } return 1 } diff --git a/cli_auth.go b/cli_auth.go new file mode 100644 index 0000000..3229463 --- /dev/null +++ b/cli_auth.go @@ -0,0 +1,470 @@ +package aperiodic + +import ( + "bytes" + "cmp" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "runtime" + "strings" + "time" +) + +// Signing in: `aperiodic login` gets this machine a named key of its own +// through aperiodic.io/device, on a laptop or a remote machine alike, and +// saves it (credentials.go). The server side is /api/v1/cli/*. + +type loginStart struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +type loginToken struct { + APIKey string `json:"api_key"` + KeyName string `json:"key_name"` + Plan string `json:"plan"` + PlanName string `json:"plan_name"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +type whoami struct { + Plan string `json:"plan"` + PlanName string `json:"plan_name"` + Key struct { + Kind string `json:"kind"` + Name string `json:"name"` + Hint string `json:"hint"` + } `json:"key"` + Covers struct { + DatasetsInFull int `json:"datasets_in_full"` + DatasetsTotal int `json:"datasets_total"` + History string `json:"history"` + Venues []string `json:"venues"` + Markets string `json:"markets"` + Live string `json:"live"` + } `json:"covers"` +} + +func (c *CLI) baseURL() string { + if url := c.Env("APERIODIC_API_URL"); url != "" { + return strings.TrimRight(url, "/") + } + return DefaultBaseURL +} + +func (c *CLI) request(method, path, apiKey string, body any) (*http.Response, error) { + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(data) + } + req, err := http.NewRequest(method, c.baseURL()+path, reader) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "aperiodic-cli") + if apiKey != "" { + req.Header.Set("X-API-KEY", apiKey) + } + if id, secret := c.Env("CF_ACCESS_CLIENT_ID"), c.Env("CF_ACCESS_CLIENT_SECRET"); id != "" && secret != "" { + req.Header.Set("CF-Access-Client-Id", id) + req.Header.Set("CF-Access-Client-Secret", secret) + } + return c.httpClient().Do(req) +} + +func (c *CLI) httpClient() *http.Client { + if c.client != nil { + return c.client + } + return &http.Client{Timeout: 30 * time.Second} +} + +// machineName names the key after this machine, so it is easy to find and +// revoke on the API page. +func (c *CLI) machineName() string { + host, err := os.Hostname() + if err != nil || host == "" { + host = "this computer" + } + return host + " (aperiodic CLI)" +} + +// canOpenBrowser is false over SSH and on a Linux machine with no display, +// where the link is for another device. +func (c *CLI) canOpenBrowser() bool { + if c.Env("SSH_CONNECTION") != "" || c.Env("SSH_TTY") != "" { + return false + } + if runtime.GOOS == "linux" && c.Env("DISPLAY") == "" && c.Env("WAYLAND_DISPLAY") == "" { + return false + } + return true +} + +func openBrowser(link string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", link).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", link).Start() + default: + return exec.Command("xdg-open", link).Start() + } +} + +// isOpenableLink is true only for a web page on the API's own host: https, or +// http on a loopback address (a local server). The OS openers also run local +// paths and other protocol handlers, so anything else is printed, not opened. +func (c *CLI) isOpenableLink(link string) bool { + target, err := url.Parse(link) + if err != nil || target.Host == "" { + return false + } + base, err := url.Parse(c.baseURL()) + if err != nil || !strings.EqualFold(target.Hostname(), base.Hostname()) { + return false + } + switch target.Scheme { + case "https": + return true + case "http": + ip := net.ParseIP(target.Hostname()) + return target.Hostname() == "localhost" || (ip != nil && ip.IsLoopback()) + } + return false +} + +func (c *CLI) runLogin(args []string) int { + fs := flag.NewFlagSet("aperiodic login", flag.ContinueOnError) + fs.SetOutput(c.Stderr) + noBrowser := fs.Bool("no-browser", false, "Print the link instead of opening a browser") + name := fs.String("name", "", "Name for this machine's key (default: the host name)") + force := fs.Bool("force", false, "Replace the key this machine already has") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + keyName := strings.TrimSpace(*name) + if keyName == "" { + keyName = c.machineName() + } + + // Every login makes a new key, and the account holds a limited number: + // keep a key that still works, and revoke the one a forced login replaces. + previous, err := LoadCredentials(c.Env) + if err != nil { + fmt.Fprintf(c.Stderr, "Warning: %v\n", err) + } + if previous != nil && !*force { + if me, _, err := c.fetchWhoami(previous.APIKey); err == nil { + fmt.Fprintf(c.Stdout, "Already signed in as %q on %s. Run 'aperiodic login --force' to replace it.\n", + cmp.Or(me.Key.Name, previous.KeyName, "this machine's key"), cmp.Or(me.PlanName, previous.PlanName)) + return 0 + } + // Rejected (revoked) or unreachable: sign in again. + } + + resp, err := c.request(http.MethodPost, "/cli/login", "", map[string]string{"name": keyName}) + if err != nil { + fmt.Fprintf(c.Stderr, "Error: could not reach Aperiodic: %v\n", err) + return 1 + } + var start loginStart + err = decodeJSON(resp, &start) + if err != nil || start.DeviceCode == "" { + fmt.Fprintf(c.Stderr, "Error: could not start the login: %v\n", errOr(err, "no code in the answer")) + return 1 + } + + opened := false + if !*noBrowser && c.canOpenBrowser() && c.isOpenableLink(start.VerificationURIComplete) { + opened = c.OpenURL(start.VerificationURIComplete) == nil + } + if opened { + fmt.Fprintln(c.Stdout, "Opened your browser to sign in. If it didn't open, go to:") + } else { + fmt.Fprintln(c.Stdout, "To sign in, open this link on any device:") + } + fmt.Fprintf(c.Stdout, "\n %s\n\n", start.VerificationURIComplete) + fmt.Fprintf(c.Stdout, "and check it shows the code %s.\n", start.UserCode) + fmt.Fprintln(c.Stdout, "Waiting for you to allow it…") + + interval := time.Duration(max(start.Interval, 1)) * time.Second + deadline := c.now().Add(time.Duration(max(start.ExpiresIn, 60)) * time.Second) + for c.now().Before(deadline) { + c.sleep(interval) + resp, err := c.request(http.MethodPost, "/cli/token", "", map[string]string{"device_code": start.DeviceCode}) + if err != nil { + // A dropped connection is not an answer: try again next round. + continue + } + var token loginToken + if err := decodeJSONAnyStatus(resp, &token); err != nil { + continue + } + switch token.Error { + case "": + return c.finishLogin(token, previous) + case "authorization_pending": + case "slow_down": + interval += 5 * time.Second + case "access_denied": + fmt.Fprintln(c.Stderr, "Error: the login was denied in the browser.") + return 1 + case "expired_token": + fmt.Fprintln(c.Stderr, "Error: the code expired. Run 'aperiodic login' again.") + return 1 + default: + fmt.Fprintf(c.Stderr, "Error: %s\n", errOr(errors.New(token.ErrorDescription), token.Error)) + return 1 + } + } + fmt.Fprintln(c.Stderr, "Error: the code expired. Run 'aperiodic login' again.") + return 1 +} + +func (c *CLI) finishLogin(token loginToken, previous *Credentials) int { + if token.APIKey == "" { + fmt.Fprintln(c.Stderr, "Error: the login finished without a key. Run 'aperiodic login' again.") + return 1 + } + path, err := SaveCredentials(c.Env, Credentials{ + APIKey: token.APIKey, + KeyName: token.KeyName, + Plan: token.Plan, + PlanName: token.PlanName, + CreatedAt: c.now().UTC().Format(time.RFC3339), + }) + if err != nil { + fmt.Fprintf(c.Stderr, "Error: signed in, but the key could not be saved: %v\n", err) + return 1 + } + fmt.Fprintf(c.Stdout, "Signed in on %s. This machine's key, %q, is saved in %s.\n", token.PlanName, token.KeyName, path) + if previous != nil && previous.APIKey != token.APIKey { + // Best effort: the old key may already be revoked, or be a default key + // the server will not revoke here. + if revoked, _ := c.revokeKey(previous.APIKey); revoked { + fmt.Fprintf(c.Stdout, "The key this machine had before, %q, is revoked.\n", previous.KeyName) + } + } + if c.Env("APERIODIC_API_KEY") != "" { + fmt.Fprintln(c.Stdout, "Note: APERIODIC_API_KEY is set in this shell, and it is used before the saved key.") + } + return 0 +} + +// revokeKey asks the server to revoke a named key with the key itself. The +// answer is whether it was revoked, and the server's reason when it was not. +func (c *CLI) revokeKey(apiKey string) (revoked bool, answer *keyAnswer) { + resp, err := c.request(http.MethodDelete, "/cli/key", apiKey, nil) + if err != nil { + return false, nil + } + defer resp.Body.Close() + answer = &keyAnswer{Status: resp.StatusCode} + _ = json.NewDecoder(resp.Body).Decode(answer) + return resp.StatusCode == http.StatusOK && answer.Revoked, answer +} + +type keyAnswer struct { + Status int `json:"-"` + Revoked bool `json:"revoked"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +func (c *CLI) runLogout(args []string) int { + if len(args) > 0 && isHelpArg(args[0]) { + fmt.Fprintln(c.Stdout, "Usage: aperiodic logout\n\nRevokes this machine's key and removes it from this machine.") + return 0 + } + creds, err := LoadCredentials(c.Env) + if err != nil { + fmt.Fprintf(c.Stderr, "Error: %v\n", err) + return 1 + } + if creds == nil { + fmt.Fprintln(c.Stdout, "Not signed in on this machine.") + return 0 + } + + revoked, answer := c.revokeKey(creds.APIKey) + if err := DeleteCredentials(c.Env); err != nil { + fmt.Fprintf(c.Stderr, "Error: %v\n", err) + return 1 + } + label := cmp.Or(creds.KeyName, "this machine's key") + switch { + case revoked: + fmt.Fprintf(c.Stdout, "Signed out. The key %q is revoked and removed from this machine.\n", label) + case answer != nil && (answer.Status == http.StatusUnauthorized || (answer.Status == http.StatusOK && !answer.Revoked)): + fmt.Fprintf(c.Stdout, "Signed out. The key %q was already revoked; it is removed from this machine.\n", label) + case answer != nil && answer.Error == "not_a_named_key": + fmt.Fprintf(c.Stdout, "Removed the key from this machine. It is your account's default key, which logout does not revoke: %s\n", + cmp.Or(answer.ErrorDescription, "rotate it at https://aperiodic.io/home/api.")) + case answer != nil: + fmt.Fprintf(c.Stdout, "Removed the key from this machine. It could not be revoked (%s): revoke %q at https://aperiodic.io/home/api.\n", + cmp.Or(answer.ErrorDescription, answer.Error, http.StatusText(answer.Status)), label) + default: + fmt.Fprintf(c.Stdout, "Removed the key from this machine. Aperiodic could not be reached to revoke it: revoke %q at https://aperiodic.io/home/api.\n", label) + } + return 0 +} + +func (c *CLI) runWhoami(args []string) int { + if len(args) > 0 && isHelpArg(args[0]) { + fmt.Fprintln(c.Stdout, "Usage: aperiodic whoami\n\nShows which key this machine uses and what its plan covers.") + return 0 + } + apiKey, source, ok := c.findAPIKey() + if !ok { + fmt.Fprintln(c.Stderr, missingKeyMessage) + return 1 + } + me, status, err := c.fetchWhoami(apiKey) + if err != nil { + if c.reportRejectedKey(status, source) { + return 1 + } + fmt.Fprintf(c.Stderr, "Error: %v\n", err) + return 1 + } + + var key string + switch me.Key.Kind { + case "named": + key = fmt.Sprintf("%q (apk_…%s)", me.Key.Name, me.Key.Hint) + case "demo": + key = "the shared demo key" + default: + key = "your account's default key" + } + fmt.Fprintf(c.Stdout, "Plan: %s\n", me.PlanName) + fmt.Fprintf(c.Stdout, "Key: %s, from %s\n", key, source) + fmt.Fprintf(c.Stdout, "Datasets: %d of %d in full\n", me.Covers.DatasetsInFull, me.Covers.DatasetsTotal) + if me.Covers.Markets != "" { + fmt.Fprintf(c.Stdout, "Markets: %s\n", me.Covers.Markets) + } + if me.Covers.History != "" { + fmt.Fprintf(c.Stdout, "History: %s\n", me.Covers.History) + } + if me.Covers.Live != "" { + fmt.Fprintf(c.Stdout, "Live: %s\n", me.Covers.Live) + } + return 0 +} + +// fetchWhoami asks what a key is and what its plan covers. status is the +// HTTP status when the server answered, 0 when it could not be reached. +func (c *CLI) fetchWhoami(apiKey string) (me whoami, status int, err error) { + resp, err := c.request(http.MethodGet, "/cli/whoami", apiKey, nil) + if err != nil { + return me, 0, fmt.Errorf("could not reach Aperiodic: %w", err) + } + status = resp.StatusCode + return me, status, decodeJSON(resp, &me) +} + +// isSavedKey reports whether a key's source is the file `aperiodic login` +// wrote, rather than APERIODIC_API_KEY or the demo key. +func (c *CLI) isSavedKey(source string) bool { + path, err := CredentialsPath(c.Env) + return err == nil && source == path +} + +// reportRejectedKey explains a 401 on the key `aperiodic login` saved, which +// may have been revoked at aperiodic.io/home/api: the way forward is to sign +// in again. It reports whether it printed anything. +func (c *CLI) reportRejectedKey(status int, source string) bool { + if status != http.StatusUnauthorized || !c.isSavedKey(source) { + return false + } + fmt.Fprintf(c.Stderr, "The key saved in %s was rejected (revoked?). Run 'aperiodic login' again.\n", source) + return true +} + +// statusOf is the HTTP status behind an API or stream handshake error, else 0. +func statusOf(err error) int { + var apiErr *APIError + if errors.As(err, &apiErr) { + return apiErr.StatusCode + } + var handshake *StreamHandshakeError + if errors.As(err, &handshake) { + return handshake.StatusCode + } + return 0 +} + +const missingKeyMessage = "Error: no API key. Run 'aperiodic login', or set APERIODIC_API_KEY." + +// findAPIKey looks for a key the way every command does: APERIODIC_API_KEY +// first, then the key `aperiodic login` saved. +func (c *CLI) findAPIKey() (key, source string, ok bool) { + if apiKey := c.Env("APERIODIC_API_KEY"); apiKey != "" { + return apiKey, "APERIODIC_API_KEY", true + } + creds, err := LoadCredentials(c.Env) + if err != nil { + fmt.Fprintf(c.Stderr, "Warning: %v\n", err) + return "", "", false + } + if creds != nil { + path, _ := CredentialsPath(c.Env) + return creds.APIKey, path, true + } + return "", "", false +} + +func decodeJSON(resp *http.Response, into any) error { + defer resp.Body.Close() + if resp.StatusCode >= 300 { + var answer struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + _ = json.NewDecoder(resp.Body).Decode(&answer) + message := answer.ErrorDescription + if message == "" { + message = answer.Error + } + if message == "" { + message = resp.Status + } + return errors.New(message) + } + return json.NewDecoder(resp.Body).Decode(into) +} + +// decodeJSONAnyStatus reads a token poll, whose 400s carry the answer. +func decodeJSONAnyStatus(resp *http.Response, into any) error { + defer resp.Body.Close() + return json.NewDecoder(resp.Body).Decode(into) +} + +func errOr(err error, fallback string) string { + if err != nil && err.Error() != "" { + return err.Error() + } + return fallback +} diff --git a/cli_auth_test.go b/cli_auth_test.go new file mode 100644 index 0000000..4aaeea3 --- /dev/null +++ b/cli_auth_test.go @@ -0,0 +1,624 @@ +package aperiodic + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +// fakeAperiodic answers /api/v1/cli/* the way aperiodic.io does: a login, +// polls that stay pending for `pendingPolls` rounds, then the key. +type fakeAperiodic struct { + mu sync.Mutex + pendingPolls int + finalError string + polls int + logins int + loginName string + revoked []string + // newKey is the key a finished login hands out (a default when empty). + newKey string + // deviceURL replaces the server's own /device link when set. + deviceURL string + // dead keys are revoked: whoami, data and DELETE /cli/key answer 401. + dead map[string]bool + // dataKeys are the keys data requests arrived with. + dataKeys []string + // deletes are the keys every DELETE /cli/key arrived with, answered or not. + deletes []string +} + +func (f *fakeAperiodic) isDead(key string) bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.dead[key] +} + +func deny(w http.ResponseWriter) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"Access denied"}`)) +} + +func (f *fakeAperiodic) handler(t *testing.T) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v1/cli/login", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.logins++ + f.loginName = body["name"] + device := f.deviceURL + f.mu.Unlock() + // Like aperiodic.io, the link is on the server's own origin. + if device == "" { + device = "http://" + r.Host + "/device?code=BCDF-GHJK" + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "device_code": "secret-device-code", + "user_code": "BCDF-GHJK", + "verification_uri": "http://" + r.Host + "/device", + "verification_uri_complete": device, + "expires_in": 600, + "interval": 5, + }) + }) + mux.HandleFunc("POST /api/v1/cli/token", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if body["device_code"] != "secret-device-code" { + t.Errorf("polled with %q", body["device_code"]) + } + f.mu.Lock() + defer f.mu.Unlock() + f.polls++ + if f.polls <= f.pendingPolls { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "authorization_pending"}) + return + } + if f.finalError != "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": f.finalError}) + return + } + key := f.newKey + if key == "" { + key = "apk_testkeytestkeytestkeytestkeytestkeytestkeyt" + } + _ = json.NewEncoder(w).Encode(map[string]string{ + "api_key": key, + "key_name": "research-vm (aperiodic CLI)", + "plan": "loggedInNoSubscription", + "plan_name": "Free", + }) + }) + mux.HandleFunc("GET /api/v1/cli/whoami", func(w http.ResponseWriter, r *http.Request) { + key := r.Header.Get("X-API-KEY") + if key == "DEMO-KEY" { + _, _ = w.Write([]byte(`{"plan":"demo","plan_name":"Preview","key":{"kind":"demo"},"covers":{"datasets_in_full":0,"datasets_total":19}}`)) + return + } + if !strings.HasPrefix(key, "apk_") || f.isDead(key) { + deny(w) + return + } + _, _ = w.Write([]byte(`{"plan":"loggedInNoSubscription","plan_name":"Free", + "key":{"kind":"named","name":"research-vm (aperiodic CLI)","hint":"keyt"}, + "covers":{"datasets_in_full":19,"datasets_total":19,"history":"History for 2025","venues":["Binance"],"markets":"BTC, ETH, SOL and 10 RWA perps","live":"Delayed live stream"}}`)) + }) + mux.HandleFunc("DELETE /api/v1/cli/key", func(w http.ResponseWriter, r *http.Request) { + key := r.Header.Get("X-API-KEY") + f.mu.Lock() + f.deletes = append(f.deletes, key) + f.mu.Unlock() + if f.isDead(key) { + deny(w) + return + } + if !strings.HasPrefix(key, "apk_") { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"not_a_named_key","error_description":"This is not a named key. Rotate an account’s default key at https://aperiodic.io/home/api."}`)) + return + } + f.mu.Lock() + f.revoked = append(f.revoked, key) + f.mu.Unlock() + _, _ = w.Write([]byte(`{"revoked":true}`)) + }) + dataHandler := func(w http.ResponseWriter, r *http.Request) { + key := r.Header.Get("X-API-KEY") + f.mu.Lock() + f.dataKeys = append(f.dataKeys, key) + f.mu.Unlock() + if f.isDead(key) { + deny(w) + return + } + _, _ = w.Write([]byte(`{"files":[],"symbols":[]}`)) + } + mux.HandleFunc("GET /api/v1/data/preview/{metric}", dataHandler) + mux.HandleFunc("GET /api/v1/data/{metric}", dataHandler) + mux.HandleFunc("GET /api/v1/metadata/symbols", dataHandler) + return mux +} + +type authHarness struct { + cli *CLI + stdout *bytes.Buffer + stderr *bytes.Buffer + env map[string]string + opened []string + slept []time.Duration +} + +func newAuthHarness(t *testing.T, fake *fakeAperiodic) *authHarness { + t.Helper() + server := httptest.NewServer(fake.handler(t)) + t.Cleanup(server.Close) + + h := &authHarness{ + stdout: &bytes.Buffer{}, + stderr: &bytes.Buffer{}, + env: map[string]string{ + "APERIODIC_API_URL": server.URL + "/api/v1", + "APERIODIC_CONFIG_DIR": t.TempDir(), + "DISPLAY": ":0", + }, + } + clock := time.Date(2026, 11, 9, 12, 0, 0, 0, time.UTC) + h.cli = &CLI{ + Stdout: h.stdout, + Stderr: h.stderr, + Env: func(key string) string { return h.env[key] }, + client: server.Client(), + OpenURL: func(url string) error { + h.opened = append(h.opened, url) + return nil + }, + sleepFunc: func(d time.Duration) { + h.slept = append(h.slept, d) + clock = clock.Add(d) + }, + nowFunc: func() time.Time { return clock }, + } + return h +} + +func TestLoginSavesAKeyOnlyThisUserCanRead(t *testing.T) { + fake := &fakeAperiodic{pendingPolls: 2} + h := newAuthHarness(t, fake) + + if code := h.cli.Run([]string{"login"}); code != 0 { + t.Fatalf("login exit %d, stderr %s", code, h.stderr) + } + + if len(h.opened) != 1 || !strings.Contains(h.opened[0], "code=BCDF-GHJK") { + t.Fatalf("opened %v", h.opened) + } + if !strings.Contains(h.stdout.String(), "BCDF-GHJK") { + t.Errorf("the code is not shown: %s", h.stdout) + } + if fake.polls != 3 { + t.Errorf("polled %d times, want 3", fake.polls) + } + if !strings.HasSuffix(fake.loginName, "(aperiodic CLI)") { + t.Errorf("key named %q", fake.loginName) + } + + creds, err := LoadCredentials(h.cli.Env) + if err != nil || creds == nil { + t.Fatalf("credentials: %v %v", creds, err) + } + if creds.APIKey != "apk_testkeytestkeytestkeytestkeytestkeytestkeyt" || creds.PlanName != "Free" { + t.Errorf("saved %+v", creds) + } + path, _ := CredentialsPath(h.cli.Env) + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + // Windows has no permission bits: Go reports any writable file as 0666, and + // the file is kept private by the ACL of the user's profile (%APPDATA%). + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Errorf("credentials mode %v, want 0600", info.Mode().Perm()) + } +} + +func TestLoginOverSSHPrintsTheLinkInsteadOfOpeningABrowser(t *testing.T) { + h := newAuthHarness(t, &fakeAperiodic{}) + h.env["SSH_CONNECTION"] = "10.0.0.1 22 10.0.0.2 51000" + + if code := h.cli.Run([]string{"login"}); code != 0 { + t.Fatalf("login exit %d: %s", code, h.stderr) + } + if len(h.opened) != 0 { + t.Errorf("opened a browser over SSH: %v", h.opened) + } + if !strings.Contains(h.stdout.String(), "open this link on any device") { + t.Errorf("stdout: %s", h.stdout) + } +} + +func TestLoginBacksOffOnSlowDownAndStopsWhenDenied(t *testing.T) { + fake := &fakeAperiodic{pendingPolls: 0, finalError: "access_denied"} + h := newAuthHarness(t, fake) + + if code := h.cli.Run([]string{"login", "--no-browser"}); code != 1 { + t.Fatalf("exit %d, want 1", code) + } + if !strings.Contains(h.stderr.String(), "denied") { + t.Errorf("stderr: %s", h.stderr) + } + if _, err := os.Stat(func() string { p, _ := CredentialsPath(h.cli.Env); return p }()); !os.IsNotExist(err) { + t.Errorf("a key was saved after a denial") + } + + slow := &fakeAperiodic{pendingPolls: 1, finalError: "slow_down"} + h2 := newAuthHarness(t, slow) + slow.pendingPolls = 0 + h2.cli.Run([]string{"login", "--no-browser"}) + if len(h2.slept) < 2 || h2.slept[1] <= h2.slept[0] { + t.Errorf("did not back off on slow_down: %v", h2.slept) + } +} + +func TestTheEnvironmentKeyComesBeforeTheSavedOne(t *testing.T) { + h := newAuthHarness(t, &fakeAperiodic{}) + if _, err := SaveCredentials(h.cli.Env, Credentials{APIKey: "apk_saved"}); err != nil { + t.Fatal(err) + } + + key, _, ok := h.cli.findAPIKey() + if !ok || key != "apk_saved" { + t.Fatalf("got %q %v", key, ok) + } + + h.env["APERIODIC_API_KEY"] = "from-env" + key, source, _ := h.cli.findAPIKey() + if key != "from-env" || source != "APERIODIC_API_KEY" { + t.Errorf("got %q from %q", key, source) + } +} + +func TestLogoutRevokesThisMachinesKeyAndRemovesIt(t *testing.T) { + fake := &fakeAperiodic{} + h := newAuthHarness(t, fake) + if _, err := SaveCredentials(h.cli.Env, Credentials{APIKey: "apk_saved", KeyName: "laptop"}); err != nil { + t.Fatal(err) + } + + if code := h.cli.Run([]string{"logout"}); code != 0 { + t.Fatalf("logout exit %d: %s", code, h.stderr) + } + if len(fake.revoked) != 1 || fake.revoked[0] != "apk_saved" { + t.Errorf("revoked %v", fake.revoked) + } + if creds, _ := LoadCredentials(h.cli.Env); creds != nil { + t.Errorf("credentials still there: %+v", creds) + } + if !strings.Contains(h.stdout.String(), "revoked") { + t.Errorf("stdout: %s", h.stdout) + } +} + +func TestWhoamiSaysWhichKeyAndWhatThePlanCovers(t *testing.T) { + h := newAuthHarness(t, &fakeAperiodic{}) + if _, err := SaveCredentials(h.cli.Env, Credentials{APIKey: "apk_saved"}); err != nil { + t.Fatal(err) + } + + if code := h.cli.Run([]string{"whoami"}); code != 0 { + t.Fatalf("whoami exit %d: %s", code, h.stderr) + } + out := h.stdout.String() + for _, want := range []string{"Free", "research-vm (aperiodic CLI)", "19 of 19", "Markets: BTC, ETH, SOL and 10 RWA perps", "History for 2025", "Delayed live stream"} { + if !strings.Contains(out, want) { + t.Errorf("whoami output lacks %q:\n%s", want, out) + } + } +} + +func TestWithoutAnyKeyCommandsSayHowToSignIn(t *testing.T) { + h := newAuthHarness(t, &fakeAperiodic{}) + + if code := h.cli.Run([]string{"whoami"}); code != 1 { + t.Fatalf("exit %d", code) + } + if !strings.Contains(h.stderr.String(), "aperiodic login") { + t.Errorf("stderr: %s", h.stderr) + } +} + +func savedPath(t *testing.T, h *authHarness) string { + t.Helper() + path, err := CredentialsPath(h.cli.Env) + if err != nil { + t.Fatal(err) + } + return path +} + +func TestLoginKeepsASavedKeyThatStillWorks(t *testing.T) { + fake := &fakeAperiodic{} + h := newAuthHarness(t, fake) + if _, err := SaveCredentials(h.cli.Env, Credentials{APIKey: "apk_saved", KeyName: "laptop"}); err != nil { + t.Fatal(err) + } + + if code := h.cli.Run([]string{"login"}); code != 0 { + t.Fatalf("login exit %d: %s", code, h.stderr) + } + want := `Already signed in as "research-vm (aperiodic CLI)" on Free. Run 'aperiodic login --force' to replace it.` + if !strings.Contains(h.stdout.String(), want) { + t.Errorf("stdout lacks %q:\n%s", want, h.stdout) + } + if fake.logins != 0 || len(h.opened) != 0 { + t.Errorf("started a login: %d logins, opened %v", fake.logins, h.opened) + } + if creds, _ := LoadCredentials(h.cli.Env); creds == nil || creds.APIKey != "apk_saved" { + t.Errorf("the saved key changed: %+v", creds) + } +} + +func TestForcedLoginRevokesTheKeyItReplaces(t *testing.T) { + fake := &fakeAperiodic{newKey: "apk_newkeynewkeynewkeynewkeynewkeynewkeynewke"} + h := newAuthHarness(t, fake) + if _, err := SaveCredentials(h.cli.Env, Credentials{APIKey: "apk_old", KeyName: "laptop"}); err != nil { + t.Fatal(err) + } + + if code := h.cli.Run([]string{"login", "--force", "--no-browser"}); code != 0 { + t.Fatalf("login exit %d: %s", code, h.stderr) + } + if fake.logins != 1 { + t.Errorf("logins %d, want 1", fake.logins) + } + if creds, _ := LoadCredentials(h.cli.Env); creds == nil || creds.APIKey != fake.newKey { + t.Errorf("saved %+v, want the new key", creds) + } + if len(fake.revoked) != 1 || fake.revoked[0] != "apk_old" { + t.Errorf("revoked %v, want the old key only", fake.revoked) + } + if !strings.Contains(h.stdout.String(), `The key this machine had before, "laptop", is revoked.`) { + t.Errorf("stdout: %s", h.stdout) + } +} + +func TestLoginReplacesASavedKeyThatWasRevoked(t *testing.T) { + fake := &fakeAperiodic{dead: map[string]bool{"apk_dead": true}} + h := newAuthHarness(t, fake) + if _, err := SaveCredentials(h.cli.Env, Credentials{APIKey: "apk_dead"}); err != nil { + t.Fatal(err) + } + + if code := h.cli.Run([]string{"login", "--no-browser"}); code != 0 { + t.Fatalf("login exit %d: %s", code, h.stderr) + } + if fake.logins != 1 { + t.Errorf("logins %d, want 1", fake.logins) + } + if creds, _ := LoadCredentials(h.cli.Env); creds == nil || creds.APIKey == "apk_dead" { + t.Errorf("saved %+v, want a new key", creds) + } + // The old key is tried once more, and its 401 is not an error. + if len(fake.deletes) != 1 || fake.deletes[0] != "apk_dead" { + t.Errorf("revoke attempts %v", fake.deletes) + } + if h.stderr.Len() != 0 { + t.Errorf("stderr: %s", h.stderr) + } +} + +func TestPreviewUsesTheDemoKeyEvenWithASavedKey(t *testing.T) { + fake := &fakeAperiodic{dead: map[string]bool{"apk_revoked": true}} + h := newAuthHarness(t, fake) + t.Setenv("APERIODIC_API_URL", h.env["APERIODIC_API_URL"]) + if _, err := SaveCredentials(h.cli.Env, Credentials{APIKey: "apk_revoked"}); err != nil { + t.Fatal(err) + } + args := []string{"ohlcv", "--preview", "--symbol", "perpetual-BTC-USDT:USDT", + "--interval", "5m", "--start-date", "2025-05-01", "--end-date", "2025-05-31", "--output-dir", t.TempDir()} + + if code := h.cli.Run(args); code != 0 { + t.Fatalf("preview exit %d: %s", code, h.stderr) + } + h.env["APERIODIC_API_KEY"] = "from-env" + if code := h.cli.Run(args); code != 0 { + t.Fatalf("preview with APERIODIC_API_KEY exit %d: %s", code, h.stderr) + } + if want := []string{"DEMO-KEY", "from-env"}; strings.Join(fake.dataKeys, ",") != strings.Join(want, ",") { + t.Errorf("data requests sent keys %v, want %v", fake.dataKeys, want) + } +} + +func TestARevokedSavedKeySaysToSignInAgain(t *testing.T) { + fake := &fakeAperiodic{dead: map[string]bool{"apk_revoked": true, "env-revoked": true}} + h := newAuthHarness(t, fake) + t.Setenv("APERIODIC_API_URL", h.env["APERIODIC_API_URL"]) + if _, err := SaveCredentials(h.cli.Env, Credentials{APIKey: "apk_revoked"}); err != nil { + t.Fatal(err) + } + want := "The key saved in " + savedPath(t, h) + " was rejected (revoked?). Run 'aperiodic login' again." + + for _, args := range [][]string{{"symbols"}, {"whoami"}} { + h.stderr.Reset() + if code := h.cli.Run(args); code != 1 { + t.Fatalf("%v exit %d", args, code) + } + if !strings.Contains(h.stderr.String(), want) { + t.Errorf("%v stderr lacks %q:\n%s", args, want, h.stderr) + } + } + + // A key from the environment is not the saved file's to explain. + h.env["APERIODIC_API_KEY"] = "env-revoked" + h.stderr.Reset() + if code := h.cli.Run([]string{"symbols"}); code != 1 { + t.Fatalf("exit %d", code) + } + if strings.Contains(h.stderr.String(), "The key saved in") || !strings.Contains(h.stderr.String(), "401") { + t.Errorf("stderr: %s", h.stderr) + } +} + +func TestLoginOpensOnlyAWebLinkOnTheAPIHost(t *testing.T) { + for _, link := range []string{"file:///etc/passwd", "https://evil.example/device?code=BCDF-GHJK"} { + fake := &fakeAperiodic{deviceURL: link} + h := newAuthHarness(t, fake) + + if code := h.cli.Run([]string{"login"}); code != 0 { + t.Fatalf("login exit %d: %s", code, h.stderr) + } + if len(h.opened) != 0 { + t.Errorf("opened %v", h.opened) + } + if !strings.Contains(h.stdout.String(), "open this link on any device:\n\n "+link) { + t.Errorf("the link is not printed:\n%s", h.stdout) + } + } +} + +func TestIsOpenableLink(t *testing.T) { + for base, cases := range map[string]map[string]bool{ + "https://aperiodic.io/api/v1": { + "https://aperiodic.io/device?code=BCDF-GHJK": true, + "https://APERIODIC.io/device": true, + "http://aperiodic.io/device": false, + "https://aperiodic.io.evil.example/device": false, + "https://evil.example/device": false, + "file:///etc/passwd": false, + `C:\Windows\System32\calc.exe`: false, + "javascript:alert(1)": false, + "ms-settings:": false, + "": false, + }, + "http://127.0.0.1:8080/api/v1": { + "http://127.0.0.1:9999/device?code=BCDF-GHJK": true, + "http://localhost/device": false, + }, + } { + c := &CLI{Env: func(key string) string { + if key == "APERIODIC_API_URL" { + return base + } + return "" + }} + for link, want := range cases { + if got := c.isOpenableLink(link); got != want { + t.Errorf("base %s: isOpenableLink(%q) = %v, want %v", base, link, got, want) + } + } + } +} + +func TestCredentialsLoadWhateverTheNotesSay(t *testing.T) { + for _, file := range []string{ + `{"api_key":"apk_saved","created_at":""}`, + `{"api_key":"apk_saved","created_at":"2026-11-10 12:00"}`, + `{"api_key":"apk_saved","created_at":"2026-11-10T12:00:00.123456+00:00","key_name":"laptop (aperiodic Python)"}`, + } { + dir := t.TempDir() + env := func(key string) string { + if key == "APERIODIC_CONFIG_DIR" { + return dir + } + return "" + } + if err := os.WriteFile(dir+"/credentials", []byte(file), 0o600); err != nil { + t.Fatal(err) + } + creds, err := LoadCredentials(env) + if err != nil || creds == nil || creds.APIKey != "apk_saved" { + t.Errorf("%s: got %+v, %v", file, creds, err) + } + } +} + +func TestSavingLeavesNoTemporaryFileAndKeepsTheModePrivate(t *testing.T) { + dir := t.TempDir() + env := func(key string) string { + if key == "APERIODIC_CONFIG_DIR" { + return dir + } + return "" + } + // A leftover from the old fixed name must not lend the file its mode. + if err := os.WriteFile(dir+"/credentials.tmp", []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + path, err := SaveCredentials(env, Credentials{APIKey: "apk_saved"}) + if err != nil { + t.Fatal(err) + } + if left, _ := filepath.Glob(filepath.Join(dir, "credentials-*")); len(left) != 0 { + t.Errorf("temporary files left: %v", left) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Errorf("mode %v, want 0600", info.Mode().Perm()) + } +} + +func TestLogoutSaysWhatTheServerSaid(t *testing.T) { + fake := &fakeAperiodic{dead: map[string]bool{"apk_gone": true}} + + h := newAuthHarness(t, fake) + if _, err := SaveCredentials(h.cli.Env, Credentials{APIKey: "11111111-2222-4333-8444-555555555555", KeyName: "default"}); err != nil { + t.Fatal(err) + } + if code := h.cli.Run([]string{"logout"}); code != 0 { + t.Fatalf("logout exit %d: %s", code, h.stderr) + } + if out := h.stdout.String(); !strings.Contains(out, "default key") || !strings.Contains(out, "Rotate an account’s default key at https://aperiodic.io/home/api.") { + t.Errorf("stdout: %s", out) + } + if creds, _ := LoadCredentials(h.cli.Env); creds != nil { + t.Errorf("credentials still there: %+v", creds) + } + + h2 := newAuthHarness(t, fake) + if _, err := SaveCredentials(h2.cli.Env, Credentials{APIKey: "apk_gone", KeyName: "old laptop"}); err != nil { + t.Fatal(err) + } + if code := h2.cli.Run([]string{"logout"}); code != 0 { + t.Fatalf("logout exit %d: %s", code, h2.stderr) + } + if !strings.Contains(h2.stdout.String(), `The key "old laptop" was already revoked`) { + t.Errorf("stdout: %s", h2.stdout) + } +} + +func TestWhoamiNamesTheDemoKey(t *testing.T) { + h := newAuthHarness(t, &fakeAperiodic{}) + h.env["APERIODIC_API_KEY"] = "DEMO-KEY" + + if code := h.cli.Run([]string{"whoami"}); code != 0 { + t.Fatalf("whoami exit %d: %s", code, h.stderr) + } + if out := h.stdout.String(); !strings.Contains(out, "the shared demo key") || strings.Contains(out, "default key") { + t.Errorf("stdout: %s", out) + } +} + +func TestLoginHelpExitsZero(t *testing.T) { + h := newAuthHarness(t, &fakeAperiodic{}) + for _, flag := range []string{"-h", "--help"} { + if code := h.cli.Run([]string{"login", flag}); code != 0 { + t.Errorf("login %s exit %d", flag, code) + } + } + if !strings.Contains(h.stderr.String(), "-force") { + t.Errorf("usage does not list --force: %s", h.stderr) + } +} diff --git a/cli_raw.go b/cli_raw.go index 8f0c1aa..6653c79 100644 --- a/cli_raw.go +++ b/cli_raw.go @@ -153,7 +153,7 @@ func (c *CLI) runRawDownload(args []string) int { } } - apiKey, ok := c.resolveAPIKey(query.Preview) + apiKey, source, ok := c.resolveAPIKey(query.Preview) if !ok { return 1 } @@ -161,7 +161,9 @@ func (c *CLI) runRawDownload(args []string) int { listing, err := client.FetchRawFiles(query, start, end) if err != nil { - c.reportRawAPIError("Error fetching raw file URLs", err, query.Preview) + if !c.reportRejectedKey(statusOf(err), source) { + c.reportRawAPIError("Error fetching raw file URLs", err, query.Preview) + } return 1 } diff --git a/cli_stream.go b/cli_stream.go index 97db0a0..dc53553 100644 --- a/cli_stream.go +++ b/cli_stream.go @@ -60,9 +60,9 @@ func (c *CLI) runStream(args []string) int { return 1 } - apiKey := c.Env("APERIODIC_API_KEY") - if apiKey == "" { - fmt.Fprintln(c.Stderr, "Error: APERIODIC_API_KEY environment variable not set") + apiKey, keySource, ok := c.findAPIKey() + if !ok { + fmt.Fprintln(c.Stderr, missingKeyMessage) return 1 } @@ -145,6 +145,7 @@ func (c *CLI) runStream(args []string) int { var handshake *StreamHandshakeError switch { + case c.reportRejectedKey(statusOf(err), keySource): case errors.As(err, &handshake): fmt.Fprintf(c.Stderr, "Error connecting to the stream: %v\n", handshake) default: diff --git a/credentials.go b/credentials.go new file mode 100644 index 0000000..8543d51 --- /dev/null +++ b/credentials.go @@ -0,0 +1,120 @@ +package aperiodic + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" +) + +// Credentials is what `aperiodic login` saves: a named key of this machine's +// own, revocable on its own at aperiodic.io/home/api. The Python client reads +// and writes the same file. Everything but api_key is a note for people, kept +// as text so that no other writer's format can make the key unreadable. +type Credentials struct { + APIKey string `json:"api_key"` + KeyName string `json:"key_name,omitempty"` + Plan string `json:"plan,omitempty"` + PlanName string `json:"plan_name,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +// CredentialsPath is where the key is kept: $APERIODIC_CONFIG_DIR, else +// $XDG_CONFIG_HOME/aperiodic, else %APPDATA%\aperiodic on Windows and +// ~/.config/aperiodic elsewhere, macOS included, so the CLI and the Python +// client agree on one place. +func CredentialsPath(env func(string) string) (string, error) { + if dir := env("APERIODIC_CONFIG_DIR"); dir != "" { + return filepath.Join(dir, "credentials"), nil + } + if dir := env("XDG_CONFIG_HOME"); dir != "" { + return filepath.Join(dir, "aperiodic", "credentials"), nil + } + if runtime.GOOS == "windows" { + if dir := env("APPDATA"); dir != "" { + return filepath.Join(dir, "aperiodic", "credentials"), nil + } + } + home := env("HOME") + if home == "" { + var err error + if home, err = os.UserHomeDir(); err != nil { + return "", fmt.Errorf("finding the home directory: %w", err) + } + } + return filepath.Join(home, ".config", "aperiodic", "credentials"), nil +} + +// LoadCredentials reads the saved key; nil, nil when there is none. +func LoadCredentials(env func(string) string) (*Credentials, error) { + path, err := CredentialsPath(env) + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + var creds Credentials + if err := json.Unmarshal(data, &creds); err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + if creds.APIKey == "" { + return nil, nil + } + return &creds, nil +} + +// SaveCredentials writes the key readable by this user only. +func SaveCredentials(env func(string) string, creds Credentials) (string, error) { + path, err := CredentialsPath(env) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", fmt.Errorf("creating %s: %w", filepath.Dir(path), err) + } + data, err := json.MarshalIndent(creds, "", " ") + if err != nil { + return "", err + } + // A fresh temporary file per write: two logins at once cannot share it, + // and no stale file left behind lends it a looser mode. + tmp, err := os.CreateTemp(filepath.Dir(path), "credentials-*") + if err != nil { + return "", fmt.Errorf("writing %s: %w", path, err) + } + defer os.Remove(tmp.Name()) + if err := tmp.Chmod(0o600); err != nil && runtime.GOOS != "windows" { + tmp.Close() + return "", fmt.Errorf("writing %s: %w", path, err) + } + if _, err := tmp.Write(append(data, '\n')); err != nil { + tmp.Close() + return "", fmt.Errorf("writing %s: %w", path, err) + } + if err := tmp.Close(); err != nil { + return "", fmt.Errorf("writing %s: %w", path, err) + } + if err := os.Rename(tmp.Name(), path); err != nil { + return "", fmt.Errorf("writing %s: %w", path, err) + } + return path, nil +} + +// DeleteCredentials removes the saved key; a missing file is not an error. +func DeleteCredentials(env func(string) string) error { + path, err := CredentialsPath(env) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("removing %s: %w", path, err) + } + return nil +}