From 440c357c3b0ad19da68960d04882e9f73a0d5942 Mon Sep 17 00:00:00 2001 From: Lukas Wuttke Date: Wed, 17 Jun 2026 19:15:07 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(cli):=20auth=20scaffold=20=E2=80=94=20?= =?UTF-8?q?login/logout/auth=20status=20+=20config=20+=20backend=20client?= =?UTF-8?q?=20(cli#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC-0001 (backend#830) Phase-1 CLI side, scaffolded ahead of the backend device-grant so it activates the moment backend#835 ships. - internal/config: ~/.tracebloc config store (0600, atomic write) — backend env + user token + active client. Fully functional + unit-tested. - internal/api: backend REST client. CLIENT_ENV -> {dev,stg,prod} base URL (matches the installer's _backend_url); proxy + CA aware (honors HTTP(S)_PROXY / NO_PROXY + the system cert pool, for corporate-proxy networks); the RFC 8628 device-flow methods (RequestDeviceCode + PollToken with the authorization_pending / slow_down / expired_token / access_denied states). Unit-tested via httptest. - internal/cli: `tracebloc login` (device flow — show URL + code, poll, store the token), `logout`, `auth status`. `client create/list/use` are stubbed (cli#84 — they need the user token from login + provisioning backend#836). login calls /device/code + /device/token, which land in backend#835; until then it reports that the backend doesn't support browser sign-in yet. Builds, gofmt-clean, unit-tested (config round-trip + 0600 mode; api URL map + device-flow poll states); `tracebloc --help` lists the new verbs. Part of cli#83 / backend#830 (end-to-end login activates with backend#835). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/api/client.go | 195 +++++++++++++++++++++++++++++++++ internal/api/client_test.go | 102 +++++++++++++++++ internal/cli/auth.go | 186 +++++++++++++++++++++++++++++++ internal/cli/client.go | 75 +++++++++++++ internal/cli/root.go | 5 + internal/config/config.go | 122 +++++++++++++++++++++ internal/config/config_test.go | 64 +++++++++++ 7 files changed, 749 insertions(+) create mode 100644 internal/api/client.go create mode 100644 internal/api/client_test.go create mode 100644 internal/cli/auth.go create mode 100644 internal/cli/client.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 00000000..769312be --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,195 @@ +// Package api is the tracebloc CLI's client for the central backend REST API +// (browser login + client provisioning). It is distinct from internal/submit, +// which talks to the in-cluster jobs-manager: this one reaches the public +// backend over real TLS. RFC-0001 (backend#830); the device-flow endpoints +// (/device/code, /device/token) land in backend#835, so RequestDeviceCode / +// PollToken are written against the RFC 8628 spec and go live when that ships. +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// Backend environments (mirror CLIENT_ENV). +const ( + EnvDev = "dev" + EnvStg = "stg" + EnvProd = "prod" +) + +const defaultTimeout = 30 * time.Second + +// BaseURL maps a CLIENT_ENV value to the backend base URL — kept in lock-step +// with the installer's `_backend_url` and client-runtime's CLIENT_ENV→backend +// mapping. Unknown / empty → prod. +func BaseURL(env string) string { + switch strings.ToLower(env) { + case EnvDev: + return "https://dev-api.tracebloc.io" + case EnvStg: + return "https://stg-api.tracebloc.io" + default: + return "https://api.tracebloc.io" + } +} + +// ResolveEnv picks the backend env: an explicit value (a --env flag) wins, +// then $CLIENT_ENV, then prod. +func ResolveEnv(explicit string) string { + if explicit != "" { + return strings.ToLower(explicit) + } + if e := os.Getenv("CLIENT_ENV"); e != "" { + return strings.ToLower(e) + } + return EnvProd +} + +// Client talks to the backend REST API. Token (the user token from login) is +// optional: the device-flow endpoints are unauthenticated; provisioning calls +// set it. +type Client struct { + BaseURL string + Token string + HTTP *http.Client +} + +// New returns a Client for the given env. The HTTP client is proxy- and +// CA-aware — it honors HTTP(S)_PROXY/NO_PROXY and the system cert pool — +// because RFC-0001 must work behind a corporate / TLS-inspecting proxy +// (backend#830 Q1). Unlike internal/submit (in-cluster, no real TLS) this +// verifies certificates: it's the public backend. +func New(env string) *Client { + return &Client{ + BaseURL: strings.TrimRight(BaseURL(env), "/"), + HTTP: &http.Client{ + Timeout: defaultTimeout, + Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, + }, + } +} + +// APIError is a non-2xx response, with the remote body surfaced verbatim. +type APIError struct { + StatusCode int + Body string + URL string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("%s returned HTTP %d: %s", e.URL, e.StatusCode, strings.TrimSpace(e.Body)) +} + +// post sends an optional JSON body and returns the status code + raw response. +func (c *Client) post(ctx context.Context, path string, body any) (int, []byte, error) { + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return 0, nil, fmt.Errorf("encoding request: %w", err) + } + rdr = bytes.NewReader(b) + } + url := c.BaseURL + path + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, rdr) + if err != nil { + return 0, nil, fmt.Errorf("building request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if c.Token != "" { + req.Header.Set("Authorization", "Token "+c.Token) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("POST %s: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, nil, fmt.Errorf("reading response from %s: %w", url, err) + } + return resp.StatusCode, raw, nil +} + +// ── Device Authorization Grant (RFC 8628) — backend endpoints land in #835 ── + +// DeviceCodeResponse is the reply from POST /device/code. +type DeviceCodeResponse 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"` +} + +// RequestDeviceCode starts the device flow. +func (c *Client) RequestDeviceCode(ctx context.Context) (*DeviceCodeResponse, error) { + url := c.BaseURL + "/device/code" + status, raw, err := c.post(ctx, "/device/code", nil) + if err != nil { + return nil, err + } + if status < 200 || status >= 300 { + return nil, &APIError{StatusCode: status, Body: string(raw), URL: url} + } + var out DeviceCodeResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("decoding device-code response: %w", err) + } + if out.DeviceCode == "" || out.UserCode == "" { + return nil, fmt.Errorf("device-code response missing device_code/user_code (got %q)", string(raw)) + } + return &out, nil +} + +// Device-flow poll outcomes (RFC 8628 §3.5): pending / slow_down mean "keep +// polling"; expired_token / access_denied are terminal. +var ( + ErrAuthorizationPending = errors.New("authorization_pending") + ErrSlowDown = errors.New("slow_down") + ErrExpiredToken = errors.New("expired_token") + ErrAccessDenied = errors.New("access_denied") +) + +// PollToken polls POST /device/token once. It returns the user token on +// approval, or one of the Err* sentinels (pending/slow_down → keep polling, +// expired/denied → stop), or an *APIError for anything else. +func (c *Client) PollToken(ctx context.Context, deviceCode string) (string, error) { + url := c.BaseURL + "/device/token" + status, raw, err := c.post(ctx, "/device/token", map[string]string{"device_code": deviceCode}) + if err != nil { + return "", err + } + var body struct { + Token string `json:"token"` + Error string `json:"error"` + } + _ = json.Unmarshal(raw, &body) // best-effort; the status + error field drive the result + if status >= 200 && status < 300 { + if body.Token == "" { + return "", fmt.Errorf("device-token success response missing token (got %q)", string(raw)) + } + return body.Token, nil + } + switch body.Error { + case "authorization_pending": + return "", ErrAuthorizationPending + case "slow_down": + return "", ErrSlowDown + case "expired_token": + return "", ErrExpiredToken + case "access_denied": + return "", ErrAccessDenied + } + return "", &APIError{StatusCode: status, Body: string(raw), URL: url} +} diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 00000000..1d5083b3 --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,102 @@ +package api + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestBaseURL(t *testing.T) { + cases := map[string]string{ + "dev": "https://dev-api.tracebloc.io", + "stg": "https://stg-api.tracebloc.io", + "prod": "https://api.tracebloc.io", + "": "https://api.tracebloc.io", + "DEV": "https://dev-api.tracebloc.io", // case-insensitive + "weird": "https://api.tracebloc.io", // unknown -> prod + } + for env, want := range cases { + if got := BaseURL(env); got != want { + t.Errorf("BaseURL(%q) = %q, want %q", env, got, want) + } + } +} + +func TestResolveEnv(t *testing.T) { + t.Setenv("CLIENT_ENV", "stg") + if got := ResolveEnv("dev"); got != "dev" { + t.Errorf("explicit should win: got %q", got) + } + if got := ResolveEnv(""); got != "stg" { + t.Errorf("CLIENT_ENV should be used: got %q", got) + } + t.Setenv("CLIENT_ENV", "") + if got := ResolveEnv(""); got != "prod" { + t.Errorf("default should be prod: got %q", got) + } +} + +func TestRequestDeviceCode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/device/code" || r.Method != http.MethodPost { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(`{"device_code":"dc","user_code":"WDJB-MJHT","verification_uri":"https://x/activate","expires_in":600,"interval":5}`)) + })) + defer srv.Close() + c := New("prod") + c.BaseURL = srv.URL + resp, err := c.RequestDeviceCode(context.Background()) + if err != nil { + t.Fatal(err) + } + if resp.UserCode != "WDJB-MJHT" || resp.Interval != 5 || resp.DeviceCode != "dc" { + t.Errorf("got %+v", resp) + } +} + +func TestPollTokenSequence(t *testing.T) { + var n int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + n++ + switch n { + case 1: + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"authorization_pending"}`)) + case 2: + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"slow_down"}`)) + default: + _, _ = w.Write([]byte(`{"token":"usertoken123"}`)) + } + })) + defer srv.Close() + c := New("prod") + c.BaseURL = srv.URL + ctx := context.Background() + if _, err := c.PollToken(ctx, "dc"); !errors.Is(err, ErrAuthorizationPending) { + t.Errorf("poll 1: want pending, got %v", err) + } + if _, err := c.PollToken(ctx, "dc"); !errors.Is(err, ErrSlowDown) { + t.Errorf("poll 2: want slow_down, got %v", err) + } + tok, err := c.PollToken(ctx, "dc") + if err != nil || tok != "usertoken123" { + t.Errorf("poll 3: want token, got %q / %v", tok, err) + } +} + +func TestPollTokenDenied(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"access_denied"}`)) + })) + defer srv.Close() + c := New("prod") + c.BaseURL = srv.URL + if _, err := c.PollToken(context.Background(), "dc"); !errors.Is(err, ErrAccessDenied) { + t.Errorf("want access_denied, got %v", err) + } +} diff --git a/internal/cli/auth.go b/internal/cli/auth.go new file mode 100644 index 00000000..4ff64bce --- /dev/null +++ b/internal/cli/auth.go @@ -0,0 +1,186 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/spf13/cobra" + + "github.com/tracebloc/cli/internal/api" + "github.com/tracebloc/cli/internal/config" + "github.com/tracebloc/cli/internal/ui" +) + +// newLoginCmd implements `tracebloc login` — browser sign-in via the OAuth 2.0 +// Device Authorization Grant (RFC 8628). Works on a headless box: the CLI shows +// a URL + short code, the human approves in a browser on any device, and the +// CLI polls until a user token is issued and stored in ~/.tracebloc (0600). +// The backend endpoints land in backend#835; until then login reports that the +// backend doesn't support browser sign-in yet. +func newLoginCmd() *cobra.Command { + var envFlag string + cmd := &cobra.Command{ + Use: "login", + Short: "Sign in to tracebloc in your browser (device flow)", + Long: `Sign in to tracebloc. The CLI prints a URL + short code; open the URL +on any device (your laptop or phone), sign in the way you already do +(password, Google, or GitHub), and approve the code. The CLI stores a +user token in ~/.tracebloc (mode 0600). + +Works on a headless / SSH box — the browser and the CLI need not share a +machine. Honors HTTP(S)_PROXY / NO_PROXY for corporate-proxy networks.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runLogin(cmd.Context(), printerFor(cmd), envFlag) + }, + } + cmd.Flags().StringVar(&envFlag, "env", "", + "backend environment: dev|stg|prod (default: $CLIENT_ENV, then prod)") + return cmd +} + +func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error { + cfg, err := config.Load() + if err != nil { + return &exitError{code: 1, err: err} + } + env := api.ResolveEnv(envFlag) + client := api.New(env) + + dc, err := client.RequestDeviceCode(ctx) + if err != nil { + var ae *api.APIError + if errors.As(err, &ae) && ae.StatusCode == http.StatusNotFound { + return &exitError{code: 1, err: fmt.Errorf( + "this backend (%s) doesn't support browser login yet — the device-grant "+ + "endpoints land in backend#835: %w", env, err)} + } + return &exitError{code: 1, err: err} + } + + p.Section("Sign in to tracebloc") + uri := dc.VerificationURIComplete + if uri == "" { + uri = dc.VerificationURI + } + p.Field("open", uri) + p.Field("code", dc.UserCode) + p.Newline() + p.Hintf("Waiting for you to approve in the browser… (Ctrl-C to cancel)") + + interval := dc.Interval + if interval <= 0 { + interval = 5 + } + var deadline time.Time + if dc.ExpiresIn > 0 { + deadline = time.Now().Add(time.Duration(dc.ExpiresIn) * time.Second) + } + + for { + if !deadline.IsZero() && time.Now().After(deadline) { + return &exitError{code: 1, err: errors.New("login timed out — re-run `tracebloc login`")} + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(interval) * time.Second): + } + + tok, err := client.PollToken(ctx, dc.DeviceCode) + switch { + case err == nil: + cfg.Env = env + cfg.Token = tok + if err := cfg.Save(); err != nil { + return &exitError{code: 1, err: err} + } + p.Newline() + p.Successf("Signed in. Token saved to ~/.tracebloc (0600).") + return nil + case errors.Is(err, api.ErrAuthorizationPending): + // not approved yet — keep polling + case errors.Is(err, api.ErrSlowDown): + interval++ + case errors.Is(err, api.ErrExpiredToken): + return &exitError{code: 1, err: errors.New("the sign-in code expired — re-run `tracebloc login`")} + case errors.Is(err, api.ErrAccessDenied): + return &exitError{code: 1, err: errors.New("sign-in was denied in the browser")} + default: + return &exitError{code: 1, err: err} + } + } +} + +// newLogoutCmd implements `tracebloc logout` — clears the stored token. +func newLogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Sign out (clear the stored token)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load() + if err != nil { + return &exitError{code: 1, err: err} + } + if !cfg.SignedIn() { + printerFor(cmd).Hintf("Already signed out.") + return nil + } + cfg.Token = "" + cfg.Email = "" + if err := cfg.Save(); err != nil { + return &exitError{code: 1, err: err} + } + printerFor(cmd).Successf("Signed out.") + return nil + }, + } +} + +// newAuthCmd is the `tracebloc auth` parent; today it carries `auth status`. +func newAuthCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Inspect tracebloc authentication state", + } + cmd.AddCommand(newAuthStatusCmd()) + return cmd +} + +// newAuthStatusCmd implements `tracebloc auth status`. +func newAuthStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show whether you're signed in, and to which backend", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load() + if err != nil { + return &exitError{code: 1, err: err} + } + p := printerFor(cmd) + if !cfg.SignedIn() { + p.Hintf("Not signed in. Run `tracebloc login`.") + return nil + } + env := cfg.Env + if env == "" { + env = api.EnvProd + } + p.Section("tracebloc auth") + p.Field("status", "signed in") + p.Field("backend", env) + if cfg.Email != "" { + p.Field("account", cfg.Email) + } + if cfg.ActiveClientID != "" { + p.Field("active client", cfg.ActiveClientID) + } + return nil + }, + } +} diff --git a/internal/cli/client.go b/internal/cli/client.go new file mode 100644 index 00000000..ddda04f7 --- /dev/null +++ b/internal/cli/client.go @@ -0,0 +1,75 @@ +package cli + +import ( + "errors" + + "github.com/spf13/cobra" +) + +// newClientCmd wires the `tracebloc client` subtree — provisioning and +// selecting the client (machine) this host enrolls as. The verbs are stubbed +// here: the implementation is cli#84 and depends on the backend device-grant +// (backend#835, for the user token from `tracebloc login`) and provisioning +// (backend#836). The command shape is in place now so the tree + help are +// stable and `--name`/`--location` are pinned. +func newClientCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "client", + Short: "Provision and manage tracebloc clients (machines)", + Long: `Provision a tracebloc client for this machine and list/select clients +in your account. + +Requires sign-in first (` + "`tracebloc login`" + `). Implemented in cli#84; +the backend it calls lands in backend#835 / #836.`, + } + cmd.AddCommand(newClientCreateCmd(), newClientListCmd(), newClientUseCmd()) + return cmd +} + +// errClientNotYet is the shared "this lands in cli#84" stub error. +func errClientNotYet() error { + return &exitError{code: 1, err: errors.New( + "`tracebloc client` is not implemented yet — it lands in cli#84 and needs the " + + "backend device-grant (backend#835) + provisioning (backend#836). " + + "`tracebloc login` is the first piece.")} +} + +func newClientCreateCmd() *cobra.Command { + var name, location string + cmd := &cobra.Command{ + Use: "create", + Short: "Provision a new client for this machine (--name, --location)", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return errClientNotYet() + }, + } + cmd.Flags().StringVar(&name, "name", "", + "human-readable client name (shown on your dashboard + carbon reports)") + cmd.Flags().StringVar(&location, "location", "", + "physical location zone for carbon footprint (e.g. DE); auto-detected + confirmed if omitted") + return cmd +} + +func newClientListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List the clients in your account", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return errClientNotYet() + }, + } +} + +func newClientUseCmd() *cobra.Command { + return &cobra.Command{ + Use: "use ", + Short: "Enroll this machine as an existing client", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, _ []string) error { + return errClientNotYet() + }, + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f1047d66..0bf6f8c0 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -81,6 +81,11 @@ what's planned next.`, root.AddCommand(newIngestCmd()) root.AddCommand(newClusterCmd()) root.AddCommand(newDatasetCmd()) + // RFC-0001 (backend#830): browser sign-in + client provisioning. + root.AddCommand(newLoginCmd()) + root.AddCommand(newLogoutCmd()) + root.AddCommand(newAuthCmd()) + root.AddCommand(newClientCmd()) // Bare `tracebloc` (no subcommand) renders a friendly home screen // instead of cobra's raw usage dump. Subcommands and --help are diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..5145fad2 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,122 @@ +// Package config persists the tracebloc CLI's user state — the backend +// environment, the user token from `tracebloc login`, and the active client +// for this machine — at ~/.tracebloc/config.json (mode 0600, it holds a +// token). RFC-0001 (backend#830). +package config + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +// Config is the on-disk CLI state. Every field is omitempty so a +// partially-configured file stays small and forward-compatible. +type Config struct { + Env string `json:"env,omitempty"` // dev|stg|prod (mirrors CLIENT_ENV) + Email string `json:"email,omitempty"` // who is signed in (display only) + Token string `json:"token,omitempty"` // user token from device login + ActiveClientID string `json:"active_client_id,omitempty"` // client this machine enrolls as +} + +// Dir is the config directory: $TRACEBLOC_CONFIG_DIR if set (tests / ops +// override), else ~/.tracebloc. +func Dir() (string, error) { + if d := os.Getenv("TRACEBLOC_CONFIG_DIR"); d != "" { + return d, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("locating home directory: %w", err) + } + return filepath.Join(home, ".tracebloc"), nil +} + +// Path is the config file path (Dir()/config.json). +func Path() (string, error) { + dir, err := Dir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.json"), nil +} + +// Load reads the config. A missing file is NOT an error — it returns an empty +// Config (a fresh machine that has never run `login`). +func Load() (*Config, error) { + path, err := Path() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return &Config{}, nil + } + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + var c Config + if err := json.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + return &c, nil +} + +// Save writes the config 0600 (creating the dir 0700), atomically: a temp file +// in the same dir then a rename, so a crash mid-write can't truncate the token. +func (c *Config) Save() error { + dir, err := Dir() + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("creating %s: %w", dir, err) + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return fmt.Errorf("encoding config: %w", err) + } + data = append(data, '\n') + + tmp, err := os.CreateTemp(dir, ".config-*.json") + if err != nil { + return fmt.Errorf("creating temp config: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op after a successful rename + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("chmod temp config: %w", err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("writing temp config: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("closing temp config: %w", err) + } + path, _ := Path() + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("replacing %s: %w", path, err) + } + return nil +} + +// Clear removes the config file (full sign-out + reset). A missing file is not +// an error. +func Clear() error { + path, err := Path() + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("removing %s: %w", path, err) + } + return nil +} + +// SignedIn reports whether a user token is stored. +func (c *Config) SignedIn() bool { return c.Token != "" } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000..53dd368b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,64 @@ +package config + +import ( + "os" + "testing" +) + +func TestSaveLoadRoundTrip(t *testing.T) { + t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) + in := &Config{Env: "dev", Email: "a@b.com", Token: "tok123", ActiveClientID: "edge_1"} + if err := in.Save(); err != nil { + t.Fatal(err) + } + out, err := Load() + if err != nil { + t.Fatal(err) + } + if *out != *in { + t.Fatalf("round-trip mismatch: %+v != %+v", out, in) + } + if !out.SignedIn() { + t.Error("SignedIn should be true when a token is present") + } +} + +func TestSaveIs0600(t *testing.T) { + t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) + if err := (&Config{Token: "secret"}).Save(); err != nil { + t.Fatal(err) + } + p, _ := Path() + fi, err := os.Stat(p) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("config mode = %v, want 0600 (it holds a token)", fi.Mode().Perm()) + } +} + +func TestLoadMissingIsEmpty(t *testing.T) { + t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) + c, err := Load() + if err != nil { + t.Fatalf("missing config should not error: %v", err) + } + if c.SignedIn() { + t.Errorf("missing config should be empty, got %+v", c) + } +} + +func TestClear(t *testing.T) { + t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) + _ = (&Config{Token: "x"}).Save() + if err := Clear(); err != nil { + t.Fatal(err) + } + if c, _ := Load(); c.SignedIn() { + t.Error("after Clear, should not be signed in") + } + if err := Clear(); err != nil { + t.Errorf("Clear on a missing file should be nil, got %v", err) + } +} From 5914a105b1d2c10a5b58bd44e9e1dda618196cf6 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:14:36 +0200 Subject: [PATCH 2/2] feat(cli): authenticate with Bearer + verify token on login (cli#83) (#86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): authenticate with Bearer + verify token on login (cli#83) Completes `tracebloc login` against the now-built device-grant endpoints (backend#846). The token the flow issues is a ClientAccessToken, which the backend authenticates as `Authorization: Bearer` (ClientAccessTokenAuthentication, backend#835) — not the legacy DRF `Token` scheme the client was sending on authenticated requests, which would have failed to authenticate a logged-in token. - internal/api: authenticated requests now send `Bearer ` (was `Token`); add get() + WhoAmI() (GET /userinfo/) to confirm the token + fetch the account. - login now verifies the freshly-issued token (best-effort) and stores/shows the account ("Signed in as you@co.com"); a failed lookup never fails a valid sign-in. - tests: WhoAmI sends Bearer + parses the identity; a 401 surfaces as an APIError. The device-flow contract (paths/fields/error codes) was already aligned with backend#846 — verified, unchanged. Stacked on cli#85 (auth scaffold). go build/vet/test green. Co-Authored-By: Claude Opus 4.8 (1M context) * test(cli): cover the login command end-to-end + add test seams (cli#83) internal/api was unit-tested, but the login / logout / auth status COMMANDS weren't. Adds auth_test.go driving the full device-flow command against an httptest backend whose shapes match backend#846 — so it also guards the CLI<->backend contract that the Token->Bearer fix corrected: - login: device_code -> authorization_pending -> token -> WhoAmI(Bearer) -> "Signed in as ..." with config persisted; the 404 "unsupported backend" gate (asserts no token is stored); access_denied. - logout clears the token; auth status (signed-in + not-signed-in). Two unexported test seams in auth.go — newAPIClient (point at an httptest server) and pollAfter (fire the poll immediately) — since the flow otherwise makes real HTTP calls on a timer. go build / vet / test green. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/api/client.go | 60 ++++++++++++- internal/api/client_test.go | 38 +++++++++ internal/cli/auth.go | 25 +++++- internal/cli/auth_test.go | 163 ++++++++++++++++++++++++++++++++++++ 4 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 internal/cli/auth_test.go diff --git a/internal/api/client.go b/internal/api/client.go index 769312be..0bb22d34 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -105,12 +105,40 @@ func (c *Client) post(ctx context.Context, path string, body any) (int, []byte, return 0, nil, fmt.Errorf("building request: %w", err) } req.Header.Set("Content-Type", "application/json") + c.setAuth(req) + resp, err := c.HTTP.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("POST %s: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, nil, fmt.Errorf("reading response from %s: %w", url, err) + } + return resp.StatusCode, raw, nil +} + +// setAuth attaches the stored token as a Bearer credential. The login token is +// a ClientAccessToken, authenticated by the backend's +// ClientAccessTokenAuthentication (keyword "Bearer", backend#835) — NOT the +// legacy DRF "Token" scheme. +func (c *Client) setAuth(req *http.Request) { if c.Token != "" { - req.Header.Set("Authorization", "Token "+c.Token) + req.Header.Set("Authorization", "Bearer "+c.Token) } +} + +// get sends an authenticated GET and returns the status code + raw response. +func (c *Client) get(ctx context.Context, path string) (int, []byte, error) { + url := c.BaseURL + path + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return 0, nil, fmt.Errorf("building request: %w", err) + } + c.setAuth(req) resp, err := c.HTTP.Do(req) if err != nil { - return 0, nil, fmt.Errorf("POST %s: %w", url, err) + return 0, nil, fmt.Errorf("GET %s: %w", url, err) } defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) @@ -193,3 +221,31 @@ func (c *Client) PollToken(ctx context.Context, deviceCode string) (string, erro } return "", &APIError{StatusCode: status, Body: string(raw), URL: url} } + +// ── Authenticated calls (Bearer ClientAccessToken) ── + +// Identity is the signed-in user, from GET /userinfo/. +type Identity struct { + Email string `json:"email"` + Type string `json:"type"` + Account string `json:"account"` +} + +// WhoAmI fetches the signed-in user from the backend, authenticating with the +// stored token (Bearer). It confirms the token is live and returns the account +// — `login` uses it to verify the credential it just obtained. Requires Token. +func (c *Client) WhoAmI(ctx context.Context) (*Identity, error) { + url := c.BaseURL + "/userinfo/" + status, raw, err := c.get(ctx, "/userinfo/") + if err != nil { + return nil, err + } + if status < 200 || status >= 300 { + return nil, &APIError{StatusCode: status, Body: string(raw), URL: url} + } + var id Identity + if err := json.Unmarshal(raw, &id); err != nil { + return nil, fmt.Errorf("decoding userinfo response: %w", err) + } + return &id, nil +} diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 1d5083b3..41af4648 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -100,3 +100,41 @@ func TestPollTokenDenied(t *testing.T) { t.Errorf("want access_denied, got %v", err) } } + +func TestWhoAmI(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/userinfo/" || r.Method != http.MethodGet { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer usertoken123" { + t.Errorf("auth header = %q, want %q", got, "Bearer usertoken123") + } + _, _ = w.Write([]byte(`{"email":"ds@tracebloc.io","type":"DS","account":"Acme"}`)) + })) + defer srv.Close() + c := New("prod") + c.BaseURL = srv.URL + c.Token = "usertoken123" + id, err := c.WhoAmI(context.Background()) + if err != nil { + t.Fatal(err) + } + if id.Email != "ds@tracebloc.io" || id.Account != "Acme" { + t.Errorf("got %+v", id) + } +} + +func TestWhoAmIUnauthorized(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"detail":"Invalid token."}`)) + })) + defer srv.Close() + c := New("prod") + c.BaseURL = srv.URL + c.Token = "bad" + var ae *APIError + if _, err := c.WhoAmI(context.Background()); !errors.As(err, &ae) || ae.StatusCode != http.StatusUnauthorized { + t.Errorf("want APIError 401, got %v", err) + } +} diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 4ff64bce..6f159653 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -42,13 +42,21 @@ machine. Honors HTTP(S)_PROXY / NO_PROXY for corporate-proxy networks.`, return cmd } +// Test seams: the device flow makes real HTTP calls on a timer, so tests +// override the client factory (point it at an httptest server) and the poll +// clock (fire immediately) rather than hitting the network / wall clock. +var ( + newAPIClient = api.New + pollAfter = time.After +) + func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error { cfg, err := config.Load() if err != nil { return &exitError{code: 1, err: err} } env := api.ResolveEnv(envFlag) - client := api.New(env) + client := newAPIClient(env) dc, err := client.RequestDeviceCode(ctx) if err != nil { @@ -87,7 +95,7 @@ func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error { select { case <-ctx.Done(): return ctx.Err() - case <-time.After(time.Duration(interval) * time.Second): + case <-pollAfter(time.Duration(interval) * time.Second): } tok, err := client.PollToken(ctx, dc.DeviceCode) @@ -95,11 +103,22 @@ func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error { case err == nil: cfg.Env = env cfg.Token = tok + // Confirm the freshly-issued token actually authenticates, and + // capture the account to show + store. Best-effort: don't fail a + // successful sign-in just because this lookup couldn't run. + client.Token = tok + if id, werr := client.WhoAmI(ctx); werr == nil { + cfg.Email = id.Email + } if err := cfg.Save(); err != nil { return &exitError{code: 1, err: err} } p.Newline() - p.Successf("Signed in. Token saved to ~/.tracebloc (0600).") + if cfg.Email != "" { + p.Successf("Signed in as %s. Token saved to ~/.tracebloc (0600).", cfg.Email) + } else { + p.Successf("Signed in. Token saved to ~/.tracebloc (0600).") + } return nil case errors.Is(err, api.ErrAuthorizationPending): // not approved yet — keep polling diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go new file mode 100644 index 00000000..5be742b6 --- /dev/null +++ b/internal/cli/auth_test.go @@ -0,0 +1,163 @@ +package cli + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/tracebloc/cli/internal/api" + "github.com/tracebloc/cli/internal/config" +) + +// withTestBackend points the login command at an httptest server (via the +// newAPIClient seam), makes polling instant (pollAfter seam), and isolates the +// on-disk config to a temp dir. All are restored on cleanup. +func withTestBackend(t *testing.T, h http.HandlerFunc) { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) + + origClient, origAfter := newAPIClient, pollAfter + newAPIClient = func(string) *api.Client { + return &api.Client{BaseURL: srv.URL, HTTP: srv.Client()} + } + pollAfter = func(time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + ch <- time.Time{} + return ch + } + t.Cleanup(func() { newAPIClient = origClient; pollAfter = origAfter }) +} + +func runCmd(t *testing.T, args ...string) (string, error) { + t.Helper() + root := NewRootCmd(BuildInfo{Version: "test"}) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(args) + err := root.Execute() + return out.String(), err +} + +func TestLogin_FullFlow(t *testing.T) { + var polls int + withTestBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/device/code": + _, _ = w.Write([]byte(`{"device_code":"dc","user_code":"WDJB-MJHT","verification_uri":"https://x/activate","expires_in":600,"interval":5}`)) + case "/device/token": + polls++ + if polls == 1 { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"authorization_pending"}`)) + return + } + _, _ = w.Write([]byte(`{"token":"cat_abc"}`)) + case "/userinfo/": + if got := r.Header.Get("Authorization"); got != "Bearer cat_abc" { + t.Errorf("userinfo auth header = %q, want %q", got, "Bearer cat_abc") + } + _, _ = w.Write([]byte(`{"email":"ds@tracebloc.io","account":"Acme"}`)) + default: + t.Errorf("unexpected request path %s", r.URL.Path) + } + }) + + out, err := runCmd(t, "login") + if err != nil { + t.Fatalf("login: %v", err) + } + if polls != 2 { + t.Errorf("expected 2 polls (pending then token), got %d", polls) + } + cfg, _ := config.Load() + if cfg.Token != "cat_abc" { + t.Errorf("stored token = %q, want cat_abc", cfg.Token) + } + if cfg.Email != "ds@tracebloc.io" { + t.Errorf("stored email = %q, want ds@tracebloc.io", cfg.Email) + } + if !strings.Contains(out, "ds@tracebloc.io") { + t.Errorf("expected output to show the account, got:\n%s", out) + } +} + +func TestLogin_BackendUnsupported(t *testing.T) { + withTestBackend(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + _, err := runCmd(t, "login") + if err == nil || !strings.Contains(err.Error(), "doesn't support browser login") { + t.Errorf("want unsupported-backend error, got %v", err) + } + cfg, _ := config.Load() + if cfg.SignedIn() { + t.Error("must not store a token when the backend has no device endpoints") + } +} + +func TestLogin_Denied(t *testing.T) { + withTestBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/device/code": + _, _ = w.Write([]byte(`{"device_code":"dc","user_code":"X","interval":5}`)) + default: + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"access_denied"}`)) + } + }) + _, err := runCmd(t, "login") + if err == nil || !strings.Contains(err.Error(), "denied") { + t.Errorf("want access-denied error, got %v", err) + } +} + +func TestLogout(t *testing.T) { + t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) + if err := (&config.Config{Token: "x", Email: "e@co"}).Save(); err != nil { + t.Fatal(err) + } + out, err := runCmd(t, "logout") + if err != nil { + t.Fatal(err) + } + cfg, _ := config.Load() + if cfg.SignedIn() { + t.Error("expected to be signed out") + } + if !strings.Contains(out, "Signed out") { + t.Errorf("got:\n%s", out) + } +} + +func TestAuthStatus_SignedIn(t *testing.T) { + t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) + if err := (&config.Config{Env: "dev", Token: "x", Email: "ds@co"}).Save(); err != nil { + t.Fatal(err) + } + out, err := runCmd(t, "auth", "status") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"signed in", "ds@co", "dev"} { + if !strings.Contains(out, want) { + t.Errorf("status output missing %q, got:\n%s", want, out) + } + } +} + +func TestAuthStatus_NotSignedIn(t *testing.T) { + t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir()) + out, err := runCmd(t, "auth", "status") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Not signed in") { + t.Errorf("got:\n%s", out) + } +}