carlos / platform Public

feat(console): app-bound cli_tokens kind — invisible to user-token doors

cli_tokens gains kind/account_id/app (additive ALTERs, duplicate column
swallowed), so a token can belong to an APP rather than a person:

- NewAppToken mints one against an account+app, with the minting member in
  `address` so it lists and revokes on their tokens page. expires_at = 0,
  "never" — recorded in spec §8 as a deliberate trade against the 90-day
  user TTL: a production signup path that dies on day 91 is a worse failure
  than a long-lived credential whose rotation is one `carlos env set` away.
- CLIAppToken resolves one to (accountID, app), and refuses user tokens.
- CLITokenAddress — the gate every existing CLI verb goes through — now
  answers only the empty (user) kind, so an app token presented to
  promote/ship/ledgers looks exactly like a token that does not exist.

The housekeeping sweep is guarded too: 0 is less than every clock reading,
so an unguarded `expires_at < now` delete would have swept precisely the
tokens that never expire.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d4755415cf645330bb626e01958b91b1bb57cebc parent ce85709
2 files changed, +270 −4
  • internal/console/authstore.go +95 −4
  • internal/console/authstore_test.go +175 −0
diff --git a/internal/console/authstore.go b/internal/console/authstore.go
index bef0fc0d3..4befa976c 100644
--- a/internal/console/authstore.go
+++ b/internal/console/authstore.go
@@ -137,9 +137,31 @@ func (s *Store) migrateCLIAuth() error {
return err
}
}
+ // New columns go in this ALTER list — the "duplicate column" error is
+ // swallowed so the list stays idempotent across reopens, the same pattern
+ // the registry migrate uses. Every one defaults to the USER-token value,
+ // so every row written before this change reads back as exactly what it
+ // was: kind '', bound to no account and no app.
+ for _, stmt := range []string{
+ `ALTER TABLE cli_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE cli_tokens ADD COLUMN account_id INTEGER NOT NULL DEFAULT 0`,
+ `ALTER TABLE cli_tokens ADD COLUMN app TEXT NOT NULL DEFAULT ''`,
+ } {
+ if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
+ return err
+ }
+ }
return nil
}
+// The two token kinds cli_tokens holds. The user kind is the empty string on
+// purpose: it is what every row written before the app kind existed already
+// says, so the default IS the safe answer.
+const (
+ cliKindUser = ""
+ cliKindApp = "app"
+)
+
// newUserCode mints the typed code, grouped XXXX-XXXX so it can be read out
// and typed back without losing your place.
func newUserCode() (string, error) {
@@ -237,7 +259,11 @@ func (s *Store) PurgeCLIAuth(now time.Time) {
return // another request is doing it
}
s.db.Exec(`DELETE FROM cli_auth_requests WHERE expires_at < ?`, now.Unix())
- s.db.Exec(`DELETE FROM cli_tokens WHERE expires_at < ?`, now.Unix())
+ // expires_at = 0 means "never" (app tokens, see NewAppToken) and must be
+ // excluded from a sweep that compares against the clock — 0 is less than
+ // every now, so an unguarded sweep would delete exactly the credentials
+ // that are supposed to outlive it.
+ s.db.Exec(`DELETE FROM cli_tokens WHERE expires_at != 0 AND expires_at < ?`, now.Unix())
}
// CLIAuthByUserCode resolves a typed code to the request it names, for the
@@ -364,6 +390,66 @@ VALUES (?, ?, ?, ?, ?, ?)`,
return CLIAuthApproved, raw, address, nil
}
+// NewAppToken mints a credential an APP holds, bound to one account and one
+// app rather than to a person. mintedBy is the member who minted it: it goes
+// in `address`, which is what the audit trail, the owner's tokens listing and
+// revocation all key on — the token speaks for an app, but a human stays
+// answerable for it.
+//
+// The token never expires (`expires_at = 0`), and that is a deliberate trade
+// against the 90-day user-token TTL, recorded in spec §8: a production signup
+// path that silently dies on day 91 is a worse failure than a long-lived
+// credential whose rotation is one `carlos env set` + bounce away. Revocation
+// (the minting member's tokens page, where it lists under their address) is
+// the control.
+func (s *Store) NewAppToken(accountID int64, app, mintedBy, label string, now time.Time) (string, error) {
+ raw, err := randomToken()
+ if err != nil {
+ return "", err
+ }
+ if _, err := s.db.Exec(`INSERT INTO cli_tokens
+(token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app)
+VALUES (?, ?, ?, '', ?, 0, ?, ?, ?)`,
+ hashToken(raw), mintedBy, label, now.Unix(), cliKindApp, accountID, app); err != nil {
+ return "", err
+ }
+ return raw, nil
+}
+
+// CLIAppToken resolves a bearer token to the account and app it is bound to;
+// ok is false for unknown, revoked and expired tokens — and for every USER
+// token, which is the containment property read from this side: the app gate
+// must not accept a person's credential just because it is valid.
+func (s *Store) CLIAppToken(rawToken string, now time.Time) (int64, string, bool) {
+ if rawToken == "" {
+ return 0, "", false
+ }
+ hash := hashToken(rawToken)
+ var storedHash, kind, app string
+ var accountID, expires int64
+ err := s.db.QueryRow(`SELECT token_hash, kind, account_id, app, expires_at
+FROM cli_tokens WHERE token_hash = ?`, hash).
+ Scan(&storedHash, &kind, &accountID, &app, &expires)
+ if err != nil {
+ return 0, "", false
+ }
+ if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 {
+ return 0, "", false
+ }
+ if kind != cliKindApp {
+ return 0, "", false
+ }
+ // 0 is "never" (see NewAppToken); any other value is still honoured, so a
+ // bounded app token remains expressible without a second code path.
+ if !(expires == 0 || now.Unix() <= expires) {
+ return 0, "", false
+ }
+ // Best-effort: "last used" is for the owner's benefit on the tokens page,
+ // and a failed write must not fail the request it is annotating.
+ s.db.Exec(`UPDATE cli_tokens SET last_used = ? WHERE token_hash = ?`, now.Unix(), hash)
+ return accountID, app, true
+}
+
// CLITokenAddress resolves a bearer token to the address that approved it; ok
// is false for unknown, revoked and expired tokens. This is the whole
// authorization decision for every CLI-facing endpoint.
@@ -372,16 +458,21 @@ func (s *Store) CLITokenAddress(rawToken string, now time.Time) (string, bool) {
return "", false
}
hash := hashToken(rawToken)
- var storedHash, addr string
+ var storedHash, addr, kind string
var expires int64
- err := s.db.QueryRow(`SELECT token_hash, address, expires_at FROM cli_tokens WHERE token_hash = ?`,
- hash).Scan(&storedHash, &addr, &expires)
+ err := s.db.QueryRow(`SELECT token_hash, address, kind, expires_at FROM cli_tokens WHERE token_hash = ?`,
+ hash).Scan(&storedHash, &addr, &kind, &expires)
if err != nil {
return "", false
}
if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 {
return "", false
}
+ // An app token presented to a user door must look exactly like a token
+ // that does not exist.
+ if kind != cliKindUser {
+ return "", false
+ }
if now.Unix() > expires {
return "", false
}
diff --git a/internal/console/authstore_test.go b/internal/console/authstore_test.go
index f448bfcef..f068fc154 100644
--- a/internal/console/authstore_test.go
+++ b/internal/console/authstore_test.go
@@ -1,6 +1,7 @@
package console
import (
+ "path/filepath"
"strings"
"testing"
"time"
@@ -209,3 +210,177 @@ func TestCLITokenLifecycle(t *testing.T) {
t.Fatal("an empty token authenticates")
}
}
+
+// mintUserToken issues an ordinary user-kind CLI token the way a human does:
+// open a login, approve it, collect it.
+func mintUserToken(t *testing.T, s *Store, address string, now time.Time) string {
+ t.Helper()
+ userCode, deviceCode, err := s.NewCLIAuth("paul@laptop", "192.0.2.7", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.ApproveCLIAuth(userCode, address, now); err != nil {
+ t.Fatal(err)
+ }
+ _, raw, _, err := s.PollCLIAuth(deviceCode, now)
+ if err != nil || raw == "" {
+ t.Fatalf("collect token: %v", err)
+ }
+ return raw
+}
+
+// The containment property, both directions: an app token is invisible to
+// every user-token door, and a user token is invisible to the app-token gate.
+func TestAppTokenContainment(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+
+ app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if app == "" || len(app) < 40 {
+ t.Fatalf("app token %q: want a high-entropy secret", app)
+ }
+ accountID, appName, ok := s.CLIAppToken(app, now)
+ if !ok || accountID != 7 || appName != "tito" {
+ t.Fatalf("CLIAppToken = %d %q %v, want 7 tito true", accountID, appName, ok)
+ }
+ // The whole point: presented at a user door it must look like nothing.
+ if addr, ok := s.CLITokenAddress(app, now); ok {
+ t.Fatalf("an app token authenticated at a user door as %q", addr)
+ }
+ // And on the kind ALONE — an app token with a live expiry (the column
+ // still honours one) must be refused because of what it is, not because
+ // the "never" sentinel happens to read as long expired.
+ dated, err := randomToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.db.Exec(`INSERT INTO cli_tokens
+(token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app)
+VALUES (?, 'paul@keymail.dev', 'dated', '', ?, ?, 'app', 9, 'tito')`,
+ hashToken(dated), now.Unix(), now.Add(CLITokenTTL).Unix()); err != nil {
+ t.Fatal(err)
+ }
+ if addr, ok := s.CLITokenAddress(dated, now); ok {
+ t.Fatalf("an unexpired app token authenticated at a user door as %q", addr)
+ }
+ if id, name, ok := s.CLIAppToken(dated, now); !ok || id != 9 || name != "tito" {
+ t.Fatalf("CLIAppToken(dated) = %d %q %v, want 9 tito true", id, name, ok)
+ }
+ if _, _, ok := s.CLIAppToken(dated, now.Add(CLITokenTTL+time.Second)); ok {
+ t.Fatal("an app token with a real expiry outlived it")
+ }
+
+ user := mintUserToken(t, s, "paul@keymail.dev", now)
+ if addr, ok := s.CLITokenAddress(user, now); !ok || addr != "paul@keymail.dev" {
+ t.Fatalf("CLITokenAddress = %q %v, want the approver", addr, ok)
+ }
+ if id, name, ok := s.CLIAppToken(user, now); ok {
+ t.Fatalf("a user token passed the app gate as %d %q", id, name)
+ }
+
+ // Unknown and empty tokens answer no at both doors.
+ if _, _, ok := s.CLIAppToken("not-a-token", now); ok {
+ t.Fatal("a made-up token passed the app gate")
+ }
+ if _, _, ok := s.CLIAppToken("", now); ok {
+ t.Fatal("an empty token passed the app gate")
+ }
+}
+
+// App tokens never expire (spec §8: a production signup path must not die on
+// day 91); user tokens still do, at CLITokenTTL.
+func TestAppTokenNeverExpires(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ user := mintUserToken(t, s, "paul@keymail.dev", now)
+
+ decade := now.Add(10 * 365 * 24 * time.Hour)
+ if _, _, ok := s.CLIAppToken(app, decade); !ok {
+ t.Fatal("an app token expired")
+ }
+ if _, ok := s.CLITokenAddress(user, now.Add(CLITokenTTL+time.Second)); ok {
+ t.Fatal("a user token outlived CLITokenTTL")
+ }
+
+ // A "never" expiry must survive the housekeeping sweep, which deletes by
+ // expires_at — the sweep is where an unguarded 0 quietly kills the fleet.
+ lastCLIPurge.Store(0)
+ defer lastCLIPurge.Store(0)
+ s.PurgeCLIAuth(decade)
+ if _, _, ok := s.CLIAppToken(app, decade); !ok {
+ t.Fatal("the purge swept an app token that never expires")
+ }
+}
+
+// An app token lists and revokes under the address that minted it — that
+// listing is the only control over a credential with no expiry.
+func TestAppTokenRevocation(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ app, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ toks, err := s.CLITokens("paul@keymail.dev")
+ if err != nil || len(toks) != 1 {
+ t.Fatalf("CLITokens = %v %v, want the app token under its minter", toks, err)
+ }
+ if toks[0].Label != "tito signup" || toks[0].ID == "" {
+ t.Fatalf("token row %+v is missing its label or id", toks[0])
+ }
+ if strings.Contains(app, toks[0].ID) {
+ t.Fatal("the listing id contains the bearer secret")
+ }
+ if err := s.RevokeCLIToken("bob@keymail.dev", toks[0].ID); err == nil {
+ t.Fatal("bob revoked paul's app token")
+ }
+ if err := s.RevokeCLIToken("paul@keymail.dev", toks[0].ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, ok := s.CLIAppToken(app, now); ok {
+ t.Fatal("a revoked app token still authenticates")
+ }
+}
+
+// The migration is additive and idempotent: reopening an existing database
+// re-runs the ALTERs (duplicate column swallowed) and rows written before the
+// change read back as the empty (user) kind — still user tokens, still working.
+func TestCLITokenMigrationReopens(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "console.db")
+ now := time.Now()
+
+ first, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ user := mintUserToken(t, first, "paul@keymail.dev", now)
+ app, err := first.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := first.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ second, err := Open(path)
+ if err != nil {
+ t.Fatalf("reopen: %v", err)
+ }
+ defer second.Close()
+ if addr, ok := second.CLITokenAddress(user, now); !ok || addr != "paul@keymail.dev" {
+ t.Fatalf("after reopen CLITokenAddress = %q %v, want the approver", addr, ok)
+ }
+ if id, name, ok := second.CLIAppToken(app, now); !ok || id != 7 || name != "tito" {
+ t.Fatalf("after reopen CLIAppToken = %d %q %v, want 7 tito true", id, name, ok)
+ }
+ if _, ok := second.CLITokenAddress(app, now); ok {
+ t.Fatal("after reopen an app token authenticated at a user door")
+ }
+}