carlos / platform Public

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>
Paul Campbell 71b311535fef554c98818fcda1de120b8df4c301 parent 16da303
4 files changed, +72 −26
  • README.md +31 −1
  • internal/console/authcli.go +1 −2
  • internal/console/authstore.go +21 −16
  • internal/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)
}