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>
2 files changed,
+270
−4
internal/console/authstore.go+95 −4internal/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") |
| + } |
| +} |