carlos / platform Public

carlos auth: login, whoami, logout — the terminal gets an identity

Prints a code rather than opening a browser or listening on localhost,
because the machine that needs the credential is usually an SSH session on
a box: a redirect flow assumes the browser can reach a port here, and it
cannot. The code travels to whatever browser the human does have.

The token lands in ~/.carlos/credentials at 0600, written through a temp
file that is chmod'd BEFORE the secret is written to it, one entry per
console origin so a laptop can hold the flagship and a self-hosted
deployment at once. It is never printed and never logged. A file other
users can read is reported, not tolerated silently.

With several credentials and no -console the CLI refuses rather than
guessing — picking the wrong deployment for you is not a convenience.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell 93a4f218c9b03ada4ec2209dda929b7874bb3688 parent 6043f9c
3 files changed, +941 −0
  • auth.go +617 −0
  • auth_test.go +320 −0
  • main.go +4 −0
diff --git a/auth.go b/auth.go
new file mode 100644
index 000000000..20afff345
--- /dev/null
+++ b/auth.go
@@ -0,0 +1,617 @@
+// The `carlos auth` verb: CLI identity.
+//
+// Before this, the CLI's authority was "whoever holds the release key or can
+// reach the box", and `carlos accounts create` had nobody to name as an
+// account's owner — which produced accounts their own creator could not open
+// in the console. `carlos auth login` gives the command line an identity, and
+// that identity is what owns the accounts it creates.
+//
+// The flow, and why it is shaped this way:
+//
+// carlos auth login -console https://platform.carlosframework.com
+// → POST /auth/cli/start the console mints a short typed code
+// for the human and a long device code
+// for this process
+// → print the URL and the code the human opens it in ANY browser they
+// are signed in to and approves
+// → POST /auth/cli/poll (loop) the console hands the token back down
+// this channel, once
+// → ~/.carlos/credentials mode 0600, one entry per console origin
+//
+// There is deliberately no local callback listener. A browser-redirect flow
+// needs the browser to reach a port on the machine running the CLI, and the
+// machine running the CLI is very often an SSH session on a box or a CI
+// shell where that is simply false. A code you can retype from a phone works
+// everywhere, which is the whole point.
+//
+// The console — not Keymail — is the authority here. It is already a
+// registered Keymail OAuth client and already knows your accounts and roles,
+// so the CLI trusts the deployment the operator actually runs.
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+const authUsage = `usage: carlos auth <login|whoami|logout>
+
+ login -console <url> log this terminal in to a deployment's console.
+ Prints a code; you approve it in a browser that
+ is already signed in to that console. No browser
+ on this machine is needed — the code travels.
+ [-label <name>] what to call this terminal on the console's
+ command-line access page (default: user@host)
+ whoami [-console <url>] ask the console who this terminal is
+ logout [-console <url>] revoke this terminal's token and forget it
+
+The token lands in ~/.carlos/credentials (mode 0600), one entry per console
+origin — a laptop can hold credentials for the flagship and a self-hosted
+deployment at once. -console may be omitted when there is exactly one.
+
+HEADLESS AND BOX-SIDE USE: do not log in. A box has no browser and no human,
+and a long-lived token on it is a standing credential nobody is watching.
+Pass -owner <address> to "carlos accounts create" instead — that is the
+documented path, and it needs no identity of its own.
+`
+
+// RunAuth dispatches the auth sub-verbs.
+func RunAuth(args []string) {
+ if len(args) == 0 {
+ fmt.Fprint(os.Stderr, authUsage)
+ os.Exit(1)
+ }
+ var err error
+ switch args[0] {
+ case "login":
+ err = authLogin(os.Stdout, args[1:])
+ case "whoami":
+ err = authWhoami(os.Stdout, args[1:])
+ case "logout":
+ err = authLogout(os.Stdout, args[1:])
+ default:
+ fmt.Fprint(os.Stderr, authUsage)
+ os.Exit(1)
+ }
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "carlos auth %s: %v\n", args[0], err)
+ os.Exit(1)
+ }
+}
+
+// ---- the credentials file ---------------------------------------------------
+
+// credential is one console's token. The token is the whole secret: it is
+// never printed, never logged, and the file it lives in is 0600.
+type credential struct {
+ Console string `json:"console"`
+ Address string `json:"address"`
+ Token string `json:"token"`
+ Label string `json:"label,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+// Expired reports whether this credential is past its console-declared life.
+// A zero ExpiresAt is treated as live: a console that declined to say is not
+// the same as one that said "now".
+func (c credential) Expired(now time.Time) bool {
+ return !c.ExpiresAt.IsZero() && now.After(c.ExpiresAt)
+}
+
+// credentialFile is the whole file. A list rather than a map so the on-disk
+// order is stable and a human can read it.
+type credentialFile struct {
+ Version int `json:"version"`
+ Credentials []credential `json:"credentials"`
+}
+
+// credentialsPath is CARLOS_CREDENTIALS, else ~/.carlos/credentials. The env
+// override exists for tests and for anyone keeping their credential somewhere
+// other than $HOME (a per-project shell, a tmpfs).
+func credentialsPath() string {
+ if p := os.Getenv("CARLOS_CREDENTIALS"); p != "" {
+ return p
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return ".carlos/credentials"
+ }
+ return filepath.Join(home, ".carlos", "credentials")
+}
+
+// loadCredentials reads the file. A missing file is an empty one, not an
+// error: "you are not logged in" is a state, and every caller has to handle
+// it anyway.
+func loadCredentials() (*credentialFile, error) {
+ f := &credentialFile{Version: 1}
+ raw, err := os.ReadFile(credentialsPath())
+ if errors.Is(err, os.ErrNotExist) {
+ return f, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ if len(bytes.TrimSpace(raw)) == 0 {
+ return f, nil
+ }
+ if err := json.Unmarshal(raw, f); err != nil {
+ return nil, fmt.Errorf("%s is not readable as credentials: %w", credentialsPath(), err)
+ }
+ f.Version = 1
+ return f, nil
+}
+
+// save writes the file 0600, through a temp file in the same directory so a
+// crash mid-write cannot leave a truncated credential store.
+func (f *credentialFile) save() error {
+ path := credentialsPath()
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return err
+ }
+ f.Version = 1
+ body, err := json.MarshalIndent(f, "", " ")
+ if err != nil {
+ return err
+ }
+ body = append(body, '\n')
+ tmp, err := os.CreateTemp(dir, ".credentials-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ // Chmod before the write, not after: the secret must never exist on disk,
+ // even for a moment, at the temp file's default permissions.
+ if err := tmp.Chmod(0o600); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(body); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmp.Name(), path)
+}
+
+// find returns the credential for a console origin, or nil.
+func (f *credentialFile) find(console string) *credential {
+ want, err := normalizeConsole(console)
+ if err != nil {
+ return nil
+ }
+ for i := range f.Credentials {
+ if f.Credentials[i].Console == want {
+ return &f.Credentials[i]
+ }
+ }
+ return nil
+}
+
+// put stores a credential, replacing any existing entry for that console.
+// One entry per origin: logging in again to one deployment must never disturb
+// another.
+func (f *credentialFile) put(c credential) {
+ if n, err := normalizeConsole(c.Console); err == nil {
+ c.Console = n
+ }
+ for i := range f.Credentials {
+ if f.Credentials[i].Console == c.Console {
+ f.Credentials[i] = c
+ return
+ }
+ }
+ f.Credentials = append(f.Credentials, c)
+}
+
+// remove drops a console's credential, reporting whether there was one.
+func (f *credentialFile) remove(console string) bool {
+ want, err := normalizeConsole(console)
+ if err != nil {
+ return false
+ }
+ for i := range f.Credentials {
+ if f.Credentials[i].Console == want {
+ f.Credentials = append(f.Credentials[:i], f.Credentials[i+1:]...)
+ return true
+ }
+ }
+ return false
+}
+
+// pick resolves which credential a verb should use. An explicit -console
+// wins; otherwise the only credential is used, and several is a refusal
+// rather than a guess — shipping to the wrong deployment because the CLI
+// picked one for you is not a mistake worth being convenient about.
+func (f *credentialFile) pick(console string) (*credential, error) {
+ if console == "" {
+ console = os.Getenv("CARLOS_CONSOLE")
+ }
+ if console != "" {
+ want, err := normalizeConsole(console)
+ if err != nil {
+ return nil, err
+ }
+ c := f.find(want)
+ if c == nil {
+ return nil, fmt.Errorf("not logged in to %s — run: carlos auth login -console %s", want, want)
+ }
+ if c.Expired(time.Now()) {
+ return nil, fmt.Errorf("the credential for %s expired %s — run: carlos auth login -console %s",
+ want, c.ExpiresAt.Format(time.RFC3339), want)
+ }
+ return c, nil
+ }
+ var live []*credential
+ for i := range f.Credentials {
+ if !f.Credentials[i].Expired(time.Now()) {
+ live = append(live, &f.Credentials[i])
+ }
+ }
+ switch len(live) {
+ case 1:
+ return live[0], nil
+ case 0:
+ return nil, errors.New("not logged in — run: carlos auth login -console <url>")
+ default:
+ var names []string
+ for _, c := range live {
+ names = append(names, c.Console)
+ }
+ return nil, fmt.Errorf("logged in to several consoles — say which with -console: %s",
+ strings.Join(names, ", "))
+ }
+}
+
+// credentialPermWarning reports a credentials file other users can read.
+// A warning rather than a refusal: locking someone out of their own tooling
+// over a mode bit helps nobody, but saying nothing lets a world-readable
+// bearer token sit there unnoticed.
+func credentialPermWarning() string {
+ path := credentialsPath()
+ fi, err := os.Stat(path)
+ if err != nil {
+ return ""
+ }
+ if fi.Mode().Perm()&0o077 == 0 {
+ return ""
+ }
+ return fmt.Sprintf("warning: %s is mode %04o — it holds a bearer token. Fix with: chmod 600 %s",
+ path, fi.Mode().Perm(), path)
+}
+
+// normalizeConsole turns whatever was typed into the origin the console calls
+// itself (its OAuth client_id): scheme + host, no trailing slash, no path.
+func normalizeConsole(in string) (string, error) {
+ in = strings.TrimSpace(in)
+ if in == "" {
+ return "", errors.New("no console URL")
+ }
+ u, err := url.Parse(in)
+ if err != nil {
+ return "", fmt.Errorf("%q is not a URL: %w", in, err)
+ }
+ if u.Scheme != "https" && u.Scheme != "http" {
+ return "", fmt.Errorf("%q needs an https:// (or http:// for a local console) URL", in)
+ }
+ if u.Host == "" {
+ return "", fmt.Errorf("%q has no host", in)
+ }
+ return u.Scheme + "://" + u.Host, nil
+}
+
+// ---- talking to the console -------------------------------------------------
+
+// consoleClient is the CLI's half of the console's JSON API.
+type consoleClient struct {
+ base string
+ token string
+ httpc *http.Client
+}
+
+func newConsoleClient(base, token string) *consoleClient {
+ return &consoleClient{base: base, token: token, httpc: &http.Client{Timeout: 30 * time.Second}}
+}
+
+// do runs one JSON round-trip. A non-200 comes back carrying the console's
+// own {"error": …} text, because that text is written for the person at the
+// terminal.
+func (c *consoleClient) do(ctx context.Context, method, path string, in, out any) (int, error) {
+ var body io.Reader
+ if in != nil {
+ raw, err := json.Marshal(in)
+ if err != nil {
+ return 0, err
+ }
+ body = bytes.NewReader(raw)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
+ if err != nil {
+ return 0, err
+ }
+ if in != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if c.token != "" {
+ req.Header.Set("Authorization", "Bearer "+c.token)
+ }
+ res, err := c.httpc.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ defer res.Body.Close()
+ raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if err != nil {
+ return res.StatusCode, err
+ }
+ if res.StatusCode != http.StatusOK {
+ var e struct {
+ Error string `json:"error"`
+ }
+ msg := res.Status
+ if json.Unmarshal(raw, &e) == nil && e.Error != "" {
+ msg = e.Error
+ }
+ return res.StatusCode, fmt.Errorf("%s", msg)
+ }
+ if out != nil {
+ if err := json.Unmarshal(raw, out); err != nil {
+ return res.StatusCode, fmt.Errorf("%s %s: unreadable response: %w", method, path, err)
+ }
+ }
+ return res.StatusCode, nil
+}
+
+type startResponse struct {
+ UserCode string `json:"user_code"`
+ DeviceCode string `json:"device_code"`
+ VerifyURI string `json:"verification_uri"`
+ VerifyURIFull string `json:"verification_uri_complete"`
+ Interval int `json:"interval"`
+ ExpiresIn int `json:"expires_in"`
+}
+
+type pollResponse struct {
+ Status string `json:"status"`
+ Token string `json:"token"`
+ Address string `json:"address"`
+ ExpiresAt string `json:"expires_at"`
+ Interval int `json:"interval"`
+}
+
+type whoamiResponse struct {
+ Address string `json:"address"`
+ Console string `json:"console"`
+ Label string `json:"label"`
+ ExpiresAt string `json:"expires_at"`
+}
+
+// ---- verbs ------------------------------------------------------------------
+
+// defaultLabel is what this terminal calls itself on the console's
+// command-line access page: user@host, so a person with three machines can
+// tell which token to revoke.
+func defaultLabel() string {
+ name := "someone"
+ if u, err := user.Current(); err == nil && u.Username != "" {
+ name = u.Username
+ }
+ host, err := os.Hostname()
+ if err != nil || host == "" {
+ host = "unknown-host"
+ }
+ return name + "@" + host
+}
+
+// pollWait bounds what a console can ask the CLI to sleep for. A console we
+// are about to trust with our identity is not one we need to defend against
+// hard, but an unbounded sleep from a JSON field is still not something to
+// hand a program.
+func pollWait(seconds int) time.Duration {
+ if seconds < 0 {
+ seconds = 0
+ }
+ if seconds > 30 {
+ seconds = 30
+ }
+ return time.Duration(seconds) * time.Second
+}
+
+func authLogin(out io.Writer, args []string) error {
+ fs := flag.NewFlagSet("auth login", flag.ContinueOnError)
+ fs.SetOutput(out)
+ console := fs.String("console", os.Getenv("CARLOS_CONSOLE"), "the console to log in to (https://…)")
+ label := fs.String("label", "", "what to call this terminal on the console (default: user@host)")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if *console == "" {
+ return errors.New("which console? pass -console https://platform.carlosframework.com " +
+ "(or set CARLOS_CONSOLE). On a box with no browser, don't log in at all — " +
+ "pass -owner <address> to `carlos accounts create` instead")
+ }
+ base, err := normalizeConsole(*console)
+ if err != nil {
+ return err
+ }
+ if *label == "" {
+ *label = defaultLabel()
+ }
+ ctx := context.Background()
+ c := newConsoleClient(base, "")
+ var start startResponse
+ if _, err := c.do(ctx, http.MethodPost, "/auth/cli/start", map[string]string{"label": *label}, &start); err != nil {
+ return fmt.Errorf("starting the login at %s: %w", base, err)
+ }
+ if start.DeviceCode == "" || start.UserCode == "" {
+ return fmt.Errorf("%s did not return a login code — is that a CARLOS console?", base)
+ }
+ link := start.VerifyURIFull
+ if link == "" {
+ link = base + "/auth/cli?code=" + url.QueryEscape(start.UserCode)
+ }
+ // Printed, never opened: this frequently runs over SSH, where opening a
+ // browser would open it on the wrong machine.
+ fmt.Fprintf(out, "\nOpen this in a browser signed in to %s:\n\n %s\n\n", base, link)
+ fmt.Fprintf(out, "and check it shows the code %s\n\n", start.UserCode)
+ fmt.Fprintf(out, "Waiting for approval (this code expires in %d minutes)…\n", start.ExpiresIn/60)
+
+ deadline := time.Now().Add(time.Duration(start.ExpiresIn) * time.Second)
+ if start.ExpiresIn <= 0 {
+ deadline = time.Now().Add(10 * time.Minute)
+ }
+ wait := pollWait(start.Interval)
+ for time.Now().Before(deadline) {
+ time.Sleep(wait)
+ var poll pollResponse
+ if _, err := c.do(ctx, http.MethodPost, "/auth/cli/poll",
+ map[string]string{"device_code": start.DeviceCode}, &poll); err != nil {
+ // A hiccup mid-poll is not a failed login: keep waiting, the
+ // human is mid-approval.
+ continue
+ }
+ if poll.Interval > 0 {
+ wait = pollWait(poll.Interval)
+ }
+ switch poll.Status {
+ case "pending":
+ continue
+ case "denied":
+ return errors.New("the console says that login was denied — nothing was issued")
+ case "expired":
+ return errors.New("that login code expired before it was approved — run this again")
+ case "unknown":
+ return errors.New("the console does not recognise this login — run this again")
+ case "approved":
+ if poll.Token == "" {
+ return errors.New("the console approved the login but returned no token")
+ }
+ now := time.Now().UTC()
+ expires := now.Add(90 * 24 * time.Hour)
+ if poll.ExpiresAt != "" {
+ if t, err := time.Parse(time.RFC3339, poll.ExpiresAt); err == nil {
+ expires = t
+ }
+ }
+ f, err := loadCredentials()
+ if err != nil {
+ return err
+ }
+ f.put(credential{Console: base, Address: poll.Address, Token: poll.Token,
+ Label: *label, CreatedAt: now, ExpiresAt: expires})
+ if err := f.save(); err != nil {
+ return fmt.Errorf("storing the credential: %w", err)
+ }
+ fmt.Fprintf(out, "\nLogged in to %s as %s.\n", base, poll.Address)
+ fmt.Fprintf(out, "Token stored in %s (mode 0600); it expires %s.\n",
+ credentialsPath(), expires.Format("2006-01-02"))
+ fmt.Fprintf(out, "Revoke it any time at %s/auth/cli/tokens\n", base)
+ return nil
+ default:
+ return fmt.Errorf("the console answered with an unknown status %q", poll.Status)
+ }
+ }
+ return errors.New("gave up waiting for approval — run this again when you're at a browser")
+}
+
+func authWhoami(out io.Writer, args []string) error {
+ fs := flag.NewFlagSet("auth whoami", flag.ContinueOnError)
+ fs.SetOutput(out)
+ console := fs.String("console", "", "which console (only needed with several)")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ f, err := loadCredentials()
+ if err != nil {
+ return err
+ }
+ c, err := f.pick(*console)
+ if err != nil {
+ return err
+ }
+ if warn := credentialPermWarning(); warn != "" {
+ fmt.Fprintln(out, warn)
+ }
+ var who whoamiResponse
+ if _, err := newConsoleClient(c.Console, c.Token).
+ do(context.Background(), http.MethodGet, "/auth/cli/whoami", nil, &who); err != nil {
+ // Deliberately shows the STORED identity as well: "the console says
+ // no" and "I could not reach the console" are different problems, and
+ // a person debugging one needs to know which they have.
+ return fmt.Errorf("%s says: %v (this machine holds a credential for %s)", c.Console, err, c.Address)
+ }
+ fmt.Fprintf(out, "%s at %s\n", who.Address, c.Console)
+ if who.Label != "" {
+ fmt.Fprintf(out, " terminal: %s\n", who.Label)
+ }
+ if who.ExpiresAt != "" {
+ fmt.Fprintf(out, " expires: %s\n", who.ExpiresAt)
+ }
+ return nil
+}
+
+func authLogout(out io.Writer, args []string) error {
+ fs := flag.NewFlagSet("auth logout", flag.ContinueOnError)
+ fs.SetOutput(out)
+ console := fs.String("console", "", "which console (only needed with several)")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ f, err := loadCredentials()
+ if err != nil {
+ return err
+ }
+ c, err := f.pick(*console)
+ if err != nil {
+ return err
+ }
+ origin := c.Console
+ // Revoke first, then forget. If the console cannot be reached the local
+ // copy still goes — but say so, because a token this machine has stopped
+ // tracking is still a live credential until it expires, and the person
+ // needs to know to kill it from the console.
+ revokeErr := func() error {
+ _, err := newConsoleClient(origin, c.Token).
+ do(context.Background(), http.MethodPost, "/auth/cli/revoke", map[string]string{}, nil)
+ return err
+ }()
+ f.remove(origin)
+ if err := f.save(); err != nil {
+ return err
+ }
+ if revokeErr != nil {
+ fmt.Fprintf(out, "Forgot the credential for %s locally, but could not revoke it there: %v\n", origin, revokeErr)
+ fmt.Fprintf(out, "It stays live until it expires — revoke it at %s/auth/cli/tokens\n", origin)
+ return nil
+ }
+ fmt.Fprintf(out, "Logged out of %s — the token is revoked.\n", origin)
+ return nil
+}
+
+// ---- what other verbs use ---------------------------------------------------
+
+// loggedInCredential is the seam every other verb reaches through: the
+// credential for a console, or nil with the reason. Never fatal — the callers
+// have their own answer for "not logged in" (usually: name -owner).
+func loggedInCredential(console string) (*credential, error) {
+ f, err := loadCredentials()
+ if err != nil {
+ return nil, err
+ }
+ return f.pick(console)
+}
diff --git a/auth_test.go b/auth_test.go
new file mode 100644
index 000000000..face9e0f1
--- /dev/null
+++ b/auth_test.go
@@ -0,0 +1,320 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// useTempCredentials points the credentials file at a scratch path for the
+// length of one test.
+func useTempCredentials(t *testing.T) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "carlos", "credentials")
+ t.Setenv("CARLOS_CREDENTIALS", path)
+ return path
+}
+
+func TestCredentialsRoundTripAndPermissions(t *testing.T) {
+ path := useTempCredentials(t)
+
+ f, err := loadCredentials()
+ if err != nil {
+ t.Fatalf("loading a missing credentials file must be empty, not an error: %v", err)
+ }
+ if len(f.Credentials) != 0 {
+ t.Fatalf("fresh file = %v, want empty", f.Credentials)
+ }
+
+ now := time.Now().UTC().Truncate(time.Second)
+ f.put(credential{Console: "https://platform.carlosframework.com", Address: "paul@keymail.dev",
+ Token: "tok-flagship", CreatedAt: now, ExpiresAt: now.Add(time.Hour)})
+ f.put(credential{Console: "https://console.example.org", Address: "paul@self.host",
+ Token: "tok-self", CreatedAt: now, ExpiresAt: now.Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ // A bearer credential on disk is 0600, and the directory it lives in is
+ // not readable by anyone else either.
+ fi, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fi.Mode().Perm() != 0o600 {
+ t.Fatalf("credentials mode = %v, want 0600", fi.Mode().Perm())
+ }
+ di, err := os.Stat(filepath.Dir(path))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if di.Mode().Perm()&0o077 != 0 {
+ t.Fatalf("credentials dir mode = %v, want no group/other bits", di.Mode().Perm())
+ }
+
+ back, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(back.Credentials) != 2 {
+ t.Fatalf("reloaded %d credentials, want 2", len(back.Credentials))
+ }
+ // One entry per console origin: a laptop talks to the flagship AND a
+ // self-hosted deployment, and re-logging in replaces only the one.
+ back.put(credential{Console: "https://platform.carlosframework.com/", Address: "paul@keymail.dev",
+ Token: "tok-flagship-2", CreatedAt: now, ExpiresAt: now.Add(time.Hour)})
+ if len(back.Credentials) != 2 {
+ t.Fatalf("re-login added an entry: %d, want 2", len(back.Credentials))
+ }
+ if c := back.find("https://platform.carlosframework.com"); c == nil || c.Token != "tok-flagship-2" {
+ t.Fatalf("find after re-login = %+v, want the new token", c)
+ }
+ if c := back.find("https://console.example.org"); c == nil || c.Token != "tok-self" {
+ t.Fatal("re-logging in to one console disturbed another")
+ }
+ if !back.remove("https://console.example.org") || len(back.Credentials) != 1 {
+ t.Fatal("remove did not remove exactly one entry")
+ }
+ if back.remove("https://nobody.example") {
+ t.Fatal("removing an absent console reported success")
+ }
+}
+
+// With no -console flag the CLI picks the only credential it has, refuses to
+// guess between several, and says how to fix it when there are none.
+func TestCredentialSelection(t *testing.T) {
+ useTempCredentials(t)
+ f, _ := loadCredentials()
+ if _, err := f.pick(""); err == nil {
+ t.Fatal("picking from an empty credentials file must fail")
+ } else if !strings.Contains(err.Error(), "carlos auth login") {
+ t.Fatalf("empty-file error %q does not name the fix", err)
+ }
+ now := time.Now().UTC()
+ f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: now.Add(time.Hour)})
+ c, err := f.pick("")
+ if err != nil || c.Console != "https://a.example" {
+ t.Fatalf("pick with one credential = %+v %v", c, err)
+ }
+ f.put(credential{Console: "https://b.example", Address: "b@keymail.dev", Token: "t2", ExpiresAt: now.Add(time.Hour)})
+ if _, err := f.pick(""); err == nil || !strings.Contains(err.Error(), "-console") {
+ t.Fatalf("pick with two credentials = %v, want a refusal naming -console", err)
+ }
+ if c, err := f.pick("https://b.example/"); err != nil || c.Token != "t2" {
+ t.Fatalf("explicit pick = %+v %v", c, err)
+ }
+ if _, err := f.pick("https://c.example"); err == nil {
+ t.Fatal("picking a console we have no credential for must fail")
+ }
+ // An expired credential is not a credential.
+ f.put(credential{Console: "https://old.example", Address: "o@keymail.dev", Token: "t3", ExpiresAt: now.Add(-time.Minute)})
+ if _, err := f.pick("https://old.example"); err == nil || !strings.Contains(err.Error(), "expired") {
+ t.Fatalf("expired pick = %v, want an expiry refusal", err)
+ }
+}
+
+func TestNormalizeConsole(t *testing.T) {
+ for in, want := range map[string]string{
+ "https://platform.carlosframework.com/": "https://platform.carlosframework.com",
+ "https://platform.carlosframework.com": "https://platform.carlosframework.com",
+ "http://127.0.0.1:8090/": "http://127.0.0.1:8090",
+ " https://platform.carlosframework.com ": "https://platform.carlosframework.com",
+ } {
+ got, err := normalizeConsole(in)
+ if err != nil || got != want {
+ t.Errorf("normalizeConsole(%q) = %q %v, want %q", in, got, err, want)
+ }
+ }
+ for _, bad := range []string{"", "platform.carlosframework.com", "ftp://x", "https://"} {
+ if got, err := normalizeConsole(bad); err == nil {
+ t.Errorf("normalizeConsole(%q) = %q, want an error", bad, got)
+ }
+ }
+}
+
+// fakeConsole answers the CLI endpoints the way the real console does, so the
+// login loop can be driven end to end without a browser.
+type fakeConsole struct {
+ ts *httptest.Server
+ polls atomic.Int32
+ approved atomic.Bool
+ denied atomic.Bool
+ revoked atomic.Bool
+ lastAuth atomic.Value // string: the bearer the last request carried
+ label atomic.Value // string
+}
+
+func newFakeConsole(t *testing.T) *fakeConsole {
+ t.Helper()
+ fc := &fakeConsole{}
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /auth/cli/start", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Content-Type") != "application/json" {
+ w.WriteHeader(http.StatusUnsupportedMediaType)
+ return
+ }
+ var req struct{ Label string }
+ json.NewDecoder(r.Body).Decode(&req)
+ fc.label.Store(req.Label)
+ json.NewEncoder(w).Encode(map[string]any{
+ "user_code": "WXYZ-4823", "device_code": "device-secret",
+ "verification_uri": fc.ts.URL + "/auth/cli",
+ "verification_uri_complete": fc.ts.URL + "/auth/cli?code=WXYZ-4823",
+ "interval": 0, "expires_in": 600,
+ })
+ })
+ mux.HandleFunc("POST /auth/cli/poll", func(w http.ResponseWriter, r *http.Request) {
+ n := fc.polls.Add(1)
+ switch {
+ case fc.denied.Load():
+ json.NewEncoder(w).Encode(map[string]any{"status": "denied", "interval": 0})
+ case fc.approved.Load() || n >= 2:
+ json.NewEncoder(w).Encode(map[string]any{
+ "status": "approved", "token": "cli-token", "address": "paul@keymail.dev",
+ "expires_at": time.Now().Add(90 * 24 * time.Hour).UTC().Format(time.RFC3339),
+ "console": fc.ts.URL,
+ })
+ default:
+ json.NewEncoder(w).Encode(map[string]any{"status": "pending", "interval": 0})
+ }
+ })
+ mux.HandleFunc("GET /auth/cli/whoami", func(w http.ResponseWriter, r *http.Request) {
+ fc.lastAuth.Store(r.Header.Get("Authorization"))
+ if r.Header.Get("Authorization") != "Bearer cli-token" || fc.revoked.Load() {
+ w.WriteHeader(http.StatusUnauthorized)
+ json.NewEncoder(w).Encode(map[string]string{"error": "not signed in"})
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "address": "paul@keymail.dev", "console": fc.ts.URL, "label": "paul@laptop",
+ "expires_at": time.Now().Add(90 * 24 * time.Hour).UTC().Format(time.RFC3339),
+ })
+ })
+ mux.HandleFunc("POST /auth/cli/revoke", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer cli-token" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ fc.revoked.Store(true)
+ json.NewEncoder(w).Encode(map[string]any{"revoked": true})
+ })
+ mux.HandleFunc("POST /auth/cli/accounts", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer cli-token" {
+ w.WriteHeader(http.StatusUnauthorized)
+ json.NewEncoder(w).Encode(map[string]string{"error": "not signed in"})
+ return
+ }
+ var req struct{ Name string }
+ json.NewDecoder(r.Body).Decode(&req)
+ json.NewEncoder(w).Encode(map[string]any{
+ "id": 7, "sqid": "bag", "name": req.Name, "owner": "paul@keymail.dev",
+ "url": fc.ts.URL + "/a/bag/",
+ })
+ })
+ fc.ts = httptest.NewServer(mux)
+ t.Cleanup(fc.ts.Close)
+ return fc
+}
+
+func TestAuthLoginStoresACredential(t *testing.T) {
+ useTempCredentials(t)
+ fc := newFakeConsole(t)
+ var out bytes.Buffer
+
+ if err := authLogin(&out, []string{"-console", fc.ts.URL, "-label", "paul@laptop"}); err != nil {
+ t.Fatalf("authLogin: %v\n%s", err, out.String())
+ }
+ // The human is told where to go and what to type — that IS the flow.
+ for _, want := range []string{"/auth/cli?code=WXYZ-4823", "WXYZ-4823", "paul@keymail.dev"} {
+ if !strings.Contains(out.String(), want) {
+ t.Fatalf("login output missing %q:\n%s", want, out.String())
+ }
+ }
+ if got, _ := fc.label.Load().(string); got != "paul@laptop" {
+ t.Fatalf("label sent = %q, want paul@laptop", got)
+ }
+ f, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := f.find(fc.ts.URL)
+ if c == nil || c.Token != "cli-token" || c.Address != "paul@keymail.dev" {
+ t.Fatalf("stored credential = %+v", c)
+ }
+ // The token is a secret: it must not be printed.
+ if strings.Contains(out.String(), "cli-token") {
+ t.Fatalf("login printed the token:\n%s", out.String())
+ }
+
+ // whoami asks the console, and says who and where.
+ out.Reset()
+ if err := authWhoami(&out, []string{"-console", fc.ts.URL}); err != nil {
+ t.Fatalf("authWhoami: %v", err)
+ }
+ if !strings.Contains(out.String(), "paul@keymail.dev") || !strings.Contains(out.String(), fc.ts.URL) {
+ t.Fatalf("whoami output:\n%s", out.String())
+ }
+
+ // logout revokes console-side and forgets locally.
+ out.Reset()
+ if err := authLogout(&out, []string{"-console", fc.ts.URL}); err != nil {
+ t.Fatalf("authLogout: %v", err)
+ }
+ if !fc.revoked.Load() {
+ t.Fatal("logout did not revoke the token at the console")
+ }
+ f, _ = loadCredentials()
+ if f.find(fc.ts.URL) != nil {
+ t.Fatal("logout left the credential on disk")
+ }
+ if err := authWhoami(&out, []string{"-console", fc.ts.URL}); err == nil {
+ t.Fatal("whoami after logout must fail")
+ }
+}
+
+func TestAuthLoginDenied(t *testing.T) {
+ useTempCredentials(t)
+ fc := newFakeConsole(t)
+ fc.denied.Store(true)
+ var out bytes.Buffer
+ err := authLogin(&out, []string{"-console", fc.ts.URL})
+ if err == nil || !strings.Contains(err.Error(), "denied") {
+ t.Fatalf("denied login = %v, want a refusal", err)
+ }
+ f, _ := loadCredentials()
+ if len(f.Credentials) != 0 {
+ t.Fatal("a denied login stored a credential")
+ }
+}
+
+func TestAuthLoginNeedsAConsole(t *testing.T) {
+ useTempCredentials(t)
+ var out bytes.Buffer
+ if err := authLogin(&out, nil); err == nil || !strings.Contains(err.Error(), "-console") {
+ t.Fatalf("login with no console = %v, want a refusal naming -console", err)
+ }
+}
+
+// A credentials file anyone can read is a credential anyone can steal: the
+// CLI says so rather than pretending it is fine.
+func TestLooseCredentialPermissionsAreReported(t *testing.T) {
+ path := useTempCredentials(t)
+ f, _ := loadCredentials()
+ f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t", ExpiresAt: time.Now().Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(path, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if warn := credentialPermWarning(); warn == "" || !strings.Contains(warn, path) {
+ t.Fatalf("permission warning = %q, want it to name %s", warn, path)
+ }
+}
diff --git a/main.go b/main.go
index b5b2e87f6..fd1d403c6 100644
--- a/main.go
+++ b/main.go
@@ -39,6 +39,8 @@ func main() {
runChannels(args)
case "rollback":
runRollback(args)
+ case "auth":
+ RunAuth(args)
case "accounts":
RunAccounts(args)
case "release-keygen":
@@ -71,6 +73,8 @@ commands:
edge run the mesh front door (router)
agent run edge + activator + release adoption on this host
add | remove | instances manage routes
+ auth login | whoami | logout — who this terminal is
+ (headless? don't log in: pass -owner instead)
accounts create | list | migrate — the tenancy primitive
ship | promote | channels publish and promote releases (-account <sqid|name>)
adopt run one release-adoption pass (the hourly timer's verb)