console: issuing a CLI token is not using it, and the README says so
PollCLIAuth hands the approving address back with the token, so the poll handler no longer re-reads the row through CLITokenAddress — which stamped "last used" on a credential at the moment it was issued. "Never used since it was issued" is precisely the signal the tokens page exists to give somebody deciding whether a terminal they don't recognise is live. README: how the CLI gets an identity, why it authenticates against the deployment's own console, and the headless rule — on a box, don't log in, name -owner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 files changed,
+72
−26
README.md+31 −1internal/console/authcli.go+1 −2internal/console/authstore.go+21 −16internal/console/authstore_test.go+19 −7
diff --git a/README.md b/README.md| index 4f5dd8f05..008acb88d 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -71,11 +71,41 @@ Clicking it while signed in as that address creates the membership. |
| **From the CLI.** Every app verb takes `-account`, a sqid or a name, |
| defaulting to `ops`: |
| - carlos accounts create -name Kass # mints the id, writes the manifest |
| + carlos auth login -console https://platform.carlosframework.com |
| + carlos accounts create -name Kass # you own it |
| carlos accounts list # sqid, name, app count |
| carlos ship -account kass -app blog ... |
| carlos add -account kass -app blog blog.example.com |
| +**Who the CLI is.** `carlos auth login` gives the command line an identity, |
| +and that identity owns the accounts it creates. It authenticates against the |
| +**deployment's own console** — already a registered Keymail OAuth client and |
| +already the authority on who belongs to what — rather than minting a second |
| +OAuth client, so there is one consent screen, one place to revoke, and a |
| +self-hoster's CLI trusts their own box. |
| + |
| +It prints a code rather than opening a browser or listening on a local port, |
| +because the machine that needs the credential is usually an SSH session where |
| +neither works. You open the printed link in whatever browser you are already |
| +signed in on, check the code matches, and approve. The token lands in |
| +`~/.carlos/credentials` (mode 0600, one entry per console origin), expires in |
| +90 days, and is listed with a revoke button at `/auth/cli/tokens`. |
| + |
| + carlos auth whoami # who this terminal is, per the console |
| + carlos auth logout # revokes the token, then forgets it |
| + |
| +**On a box, or in CI, don't log in.** There is no browser and no human, and a |
| +long-lived token sitting on a box is a standing credential nobody is watching. |
| +Name the owner outright instead — it needs no identity of its own and takes |
| +precedence over any logged-in one: |
| + |
| + carlos accounts create -name Kass -owner paul@keymail.dev |
| + |
| +Either way an owner is named. `carlos accounts create` **refuses** with no |
| +login and no `-owner`: an account with no owner materializes into the console |
| +with no members, which means nobody can open it, invite to it, or see the apps |
| +migrated into it. |
| + |
| An exact sqid wins; otherwise a unique name match; an ambiguous name lists the |
| candidates and refuses rather than guessing which tenant you meant. |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index b92c54653..e3e15cfcf 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -270,7 +270,7 @@ func (s *Server) handleCLIPoll(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusTooManyRequests, "polling too fast") |
| return |
| } |
| - status, token, err := s.store.PollCLIAuth(req.DeviceCode, time.Now()) |
| + status, token, addr, err := s.store.PollCLIAuth(req.DeviceCode, time.Now()) |
| if err != nil { |
| log.Printf("console: cli auth poll: %v", err) |
| jsonError(w, http.StatusInternalServerError, "could not check the login — try again") |
| @@ -278,7 +278,6 @@ func (s *Server) handleCLIPoll(w http.ResponseWriter, r *http.Request) { |
| } |
| out := map[string]any{"status": status, "interval": cliPollInterval} |
| if status == CLIAuthApproved { |
| - addr, _ := s.store.CLITokenAddress(token, time.Now()) |
| out["token"] = token |
| out["address"] = addr |
| out["expires_at"] = time.Now().Add(CLITokenTTL).UTC().Format(time.RFC3339) |
diff --git a/internal/console/authstore.go b/internal/console/authstore.go| index 18341ca79..ff01927e4 100644 |
| --- a/internal/console/authstore.go |
| +++ b/internal/console/authstore.go |
| @@ -271,64 +271,69 @@ WHERE user_code = ? AND state = ? AND expires_at >= ?`, |
| // mints the token, records it, deletes the request, and returns the raw token |
| // exactly once — the caller never gets a second copy, so a stolen device code |
| // replayed later collects nothing. |
| -func (s *Store) PollCLIAuth(deviceCode string, now time.Time) (status, rawToken string, err error) { |
| +// The approving address comes back with the token so the caller never has to |
| +// re-read the row to learn it — reading it back through CLITokenAddress would |
| +// stamp "last used" on a credential at the moment it was issued, and "never |
| +// used since it was issued" is exactly the thing the tokens page exists to |
| +// show. |
| +func (s *Store) PollCLIAuth(deviceCode string, now time.Time) (status, rawToken, address string, err error) { |
| if deviceCode == "" { |
| - return CLIAuthUnknown, "", nil |
| + return CLIAuthUnknown, "", "", nil |
| } |
| tx, err := s.db.Begin() |
| if err != nil { |
| - return "", "", err |
| + return "", "", "", err |
| } |
| defer tx.Rollback() |
| hash := hashToken(deviceCode) |
| - var storedHash, state, address, label, remote string |
| + var storedHash, state, label, remote string |
| var expires int64 |
| err = tx.QueryRow(`SELECT code_hash, state, address, label, remote_addr, expires_at |
| FROM cli_auth_requests WHERE code_hash = ?`, hash). |
| Scan(&storedHash, &state, &address, &label, &remote, &expires) |
| if err == sql.ErrNoRows { |
| - return CLIAuthUnknown, "", nil |
| + return CLIAuthUnknown, "", "", nil |
| } |
| if err != nil { |
| - return "", "", err |
| + return "", "", "", err |
| } |
| // Belt and braces over the indexed lookup: the comparison that decides |
| // whether this caller holds the secret is constant time. |
| if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 { |
| - return CLIAuthUnknown, "", nil |
| + return CLIAuthUnknown, "", "", nil |
| } |
| if now.Unix() > expires { |
| - return CLIAuthExpired, "", nil |
| + return CLIAuthExpired, "", "", nil |
| } |
| switch state { |
| case CLIAuthPending: |
| - return CLIAuthPending, "", nil |
| + return CLIAuthPending, "", "", nil |
| case CLIAuthDenied: |
| - return CLIAuthDenied, "", nil |
| + return CLIAuthDenied, "", "", nil |
| case CLIAuthApproved: |
| default: |
| - return CLIAuthUnknown, "", nil |
| + return CLIAuthUnknown, "", "", nil |
| } |
| raw, err := randomToken() |
| if err != nil { |
| - return "", "", err |
| + return "", "", "", err |
| } |
| if _, err := tx.Exec(`INSERT INTO cli_tokens |
| (token_hash, address, label, remote_addr, created_at, expires_at) |
| VALUES (?, ?, ?, ?, ?, ?)`, |
| hashToken(raw), address, label, remote, now.Unix(), now.Add(CLITokenTTL).Unix()); err != nil { |
| - return "", "", err |
| + return "", "", "", err |
| } |
| // Burn the request in the same transaction that mints the token: the two |
| // must never be separable, or a crash could hand out two tokens for one |
| // approval. |
| if _, err := tx.Exec(`DELETE FROM cli_auth_requests WHERE code_hash = ?`, hash); err != nil { |
| - return "", "", err |
| + return "", "", "", err |
| } |
| if err := tx.Commit(); err != nil { |
| - return "", "", err |
| + return "", "", "", err |
| } |
| - return CLIAuthApproved, raw, nil |
| + return CLIAuthApproved, raw, address, nil |
| } |
| // CLITokenAddress resolves a bearer token to the address that approved it; ok |
diff --git a/internal/console/authstore_test.go b/internal/console/authstore_test.go| index 504a2a262..f448bfcef 100644 |
| --- a/internal/console/authstore_test.go |
| +++ b/internal/console/authstore_test.go |
| @@ -33,20 +33,32 @@ func TestCLIAuthApproveThenPollIssuesOneToken(t *testing.T) { |
| } |
| // Before approval the CLI is told to keep waiting and gets nothing. |
| - if status, tok, err := s.PollCLIAuth(deviceCode, now); err != nil || status != CLIAuthPending || tok != "" { |
| + if status, tok, _, err := s.PollCLIAuth(deviceCode, now); err != nil || status != CLIAuthPending || tok != "" { |
| t.Fatalf("poll before approval = %q %q %v, want pending and no token", status, tok, err) |
| } |
| if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", now); err != nil { |
| t.Fatal(err) |
| } |
| - status, raw, err := s.PollCLIAuth(deviceCode, now) |
| + status, raw, who, err := s.PollCLIAuth(deviceCode, now) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if status != CLIAuthApproved || raw == "" { |
| t.Fatalf("poll after approval = %q %q, want a token", status, raw) |
| } |
| + if who != "paul@keymail.dev" { |
| + t.Fatalf("poll returned address %q, want the approver's", who) |
| + } |
| + // Issuing is not using: a token nobody has spent yet must SAY so on the |
| + // tokens page, or "last used" is worthless as a way to spot a stolen one. |
| + toks, err := s.CLITokens("paul@keymail.dev") |
| + if err != nil || len(toks) != 1 { |
| + t.Fatalf("CLITokens = %v %v, want one", toks, err) |
| + } |
| + if !toks[0].LastUsed.IsZero() { |
| + t.Fatalf("a freshly issued token reports last used %v", toks[0].LastUsed) |
| + } |
| // The token is bound to the address that approved it. |
| addr, ok := s.CLITokenAddress(raw, now) |
| @@ -55,7 +67,7 @@ func TestCLIAuthApproveThenPollIssuesOneToken(t *testing.T) { |
| } |
| // The request is single-use: a replayed device code issues nothing. |
| - if status, tok, _ := s.PollCLIAuth(deviceCode, now); status != CLIAuthUnknown || tok != "" { |
| + if status, tok, _, _ := s.PollCLIAuth(deviceCode, now); status != CLIAuthUnknown || tok != "" { |
| t.Fatalf("replayed poll = %q %q, want unknown and no token", status, tok) |
| } |
| if _, err := s.CLIAuthByUserCode(userCode, now); err == nil { |
| @@ -73,7 +85,7 @@ func TestCLIAuthDenyIsFinal(t *testing.T) { |
| if err := s.DenyCLIAuth(userCode, now); err != nil { |
| t.Fatal(err) |
| } |
| - if status, tok, _ := s.PollCLIAuth(deviceCode, now); status != CLIAuthDenied || tok != "" { |
| + if status, tok, _, _ := s.PollCLIAuth(deviceCode, now); status != CLIAuthDenied || tok != "" { |
| t.Fatalf("poll after deny = %q %q, want denied", status, tok) |
| } |
| // Approving a denied request must not resurrect it. |
| @@ -96,7 +108,7 @@ func TestCLIAuthExpires(t *testing.T) { |
| if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", later); err == nil { |
| t.Fatal("an expired request was approvable") |
| } |
| - if status, tok, _ := s.PollCLIAuth(deviceCode, later); status != CLIAuthExpired || tok != "" { |
| + if status, tok, _, _ := s.PollCLIAuth(deviceCode, later); status != CLIAuthExpired || tok != "" { |
| t.Fatalf("poll after expiry = %q %q, want expired", status, tok) |
| } |
| } |
| @@ -113,7 +125,7 @@ func TestCLIAuthApprovedButUncollectedExpires(t *testing.T) { |
| if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", now); err != nil { |
| t.Fatal(err) |
| } |
| - if status, tok, _ := s.PollCLIAuth(deviceCode, now.Add(CLIAuthTTL+time.Second)); status != CLIAuthExpired || tok != "" { |
| + if status, tok, _, _ := s.PollCLIAuth(deviceCode, now.Add(CLIAuthTTL+time.Second)); status != CLIAuthExpired || tok != "" { |
| t.Fatalf("late collection = %q %q, want expired with no token", status, tok) |
| } |
| } |
| @@ -130,7 +142,7 @@ func TestCLITokenLifecycle(t *testing.T) { |
| if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", now); err != nil { |
| t.Fatal(err) |
| } |
| - _, raw, err := s.PollCLIAuth(deviceCode, now) |
| + _, raw, _, err := s.PollCLIAuth(deviceCode, now) |
| if err != nil || raw == "" { |
| t.Fatalf("mint %s: %v", label, err) |
| } |