carlos / platform Public

console: CLI login requests and bearer tokens — the storage half

The device-grant shape (RFC 8628) rather than a local callback listener:
the terminal that needs a credential is often an SSH session with no
browser on it, and a redirect flow assumes one. So a short typed code
crosses to whatever browser the human does have, and a 256-bit device
code — hashed at rest, compared in constant time — is what the CLI polls
with.

The token is minted at COLLECTION, not at approval, so no bearer
credential ever sits at rest waiting to be picked up, and the mint and
the burn of the request share one transaction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell 00221bc88160b6027d7c9471f7f8a862985f3a36 parent fc94c67
3 files changed, +616 −1
  • internal/console/authstore.go +411 −0
  • internal/console/authstore_test.go +199 −0
  • internal/console/store.go +6 −1
diff --git a/internal/console/authstore.go b/internal/console/authstore.go
new file mode 100644
index 000000000..18341ca79
--- /dev/null
+++ b/internal/console/authstore.go
@@ -0,0 +1,411 @@
+// CLI identity, storage half: the approval requests `carlos auth login`
+// opens, and the bearer tokens the console issues when a human approves one.
+//
+// Shape borrowed from the OAuth 2.1 device authorization grant (RFC 8628),
+// because the CLI's real constraint is the same one that grant exists for:
+// the terminal asking for the credential frequently has no browser on it —
+// it is an SSH session on a box, or a headless CI shell. So the console
+// prints a SHORT code the human carries to any browser they do have, and the
+// CLI polls with a long code it never shows anyone. No local callback
+// listener, no assumption that localhost:port is reachable from the browser.
+//
+// Two secrets, deliberately different:
+//
+// - the user code is short (39 bits) because a human types it. It is stored
+// in the clear — it must be looked up BY value when the human types it —
+// and is only safe because it lives ten minutes, is rate-limited at the
+// door, and grants nothing on its own: approving it hands the token to
+// whoever holds the DEVICE code, not to whoever typed the user code.
+// - the device code is 256 bits and is the CLI's bearer secret for the
+// length of the flow. Only its sha256 is stored, like sessions.
+package console
+
+import (
+ "crypto/rand"
+ "crypto/subtle"
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+const (
+ // CLIAuthTTL bounds one login attempt — the same ten minutes the browser
+ // OAuth flow gets. It bounds the APPROVED state too: an approval nobody
+ // collected is a CLI that walked away, and it must not mint a token later.
+ CLIAuthTTL = 10 * time.Minute
+
+ // CLITokenTTL is how long an issued CLI token lives: 90 days.
+ //
+ // Longer than the 30-day browser session on purpose. Re-authenticating a
+ // CLI is not a click — it is "open a browser, find the code, approve" —
+ // and a credential that expires monthly trains people to paste the token
+ // into a shell profile or a CI secret and never think about it again,
+ // which is strictly worse than a bounded, revocable, listed token. 90 days
+ // is a quarter: long enough that nobody routes around it, short enough
+ // that a laptop that fell out of a bag stops being an identity by itself.
+ // Every token is revocable the moment it is suspect (see CLITokens).
+ CLITokenTTL = 90 * 24 * time.Hour
+
+ // userCodeLen is the length of the typed code, before the grouping dash.
+ // Eight characters of a 31-symbol alphabet is ~39.6 bits, which with a
+ // ten-minute life and a rate-limited approval endpoint is far more than
+ // guessing survives.
+ userCodeLen = 8
+
+ // userCodeAlphabet has no 0/O, 1/I/L or U (which turns into V in some
+ // hands and is the usual source of accidental profanity in code
+ // generators). What is left is unambiguous read aloud down a phone.
+ userCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTVWXYZ"
+)
+
+// Poll statuses. Ours, not RFC 8628's error codes: this is the console's own
+// endpoint and a plain word is what the CLI prints to a human.
+const (
+ CLIAuthPending = "pending"
+ CLIAuthApproved = "approved"
+ CLIAuthDenied = "denied"
+ CLIAuthExpired = "expired"
+ // CLIAuthUnknown covers "never existed" and "already collected" with one
+ // answer, so a device code cannot be used to probe for live flows.
+ CLIAuthUnknown = "unknown"
+)
+
+// CLIAuthRequest is one pending login, as the approval page sees it. It
+// deliberately carries no secret: the page renders the label and where the
+// request came from so a human can recognise their own terminal.
+type CLIAuthRequest struct {
+ UserCode string
+ Label string
+ RemoteAddr string
+ CreatedAt time.Time
+ ExpiresAt time.Time
+}
+
+// CLIToken is one issued credential as its owner sees it on the console. ID
+// is the token's sha256, which is safe to put in a page and in a form: it
+// names the row without being the bearer secret.
+type CLIToken struct {
+ ID string
+ Address string
+ Label string
+ CreatedAt time.Time
+ ExpiresAt time.Time
+ LastUsed time.Time
+}
+
+// migrateCLIAuth creates the CLI-identity tables. Additive and idempotent,
+// called from Store.migrate.
+func (s *Store) migrateCLIAuth() error {
+ for _, stmt := range []string{
+ // One in-flight login. code_hash is the sha256 of the device code;
+ // user_code is stored in the clear because the human types it and it
+ // has to be looked up by value.
+ `CREATE TABLE IF NOT EXISTS cli_auth_requests (
+ code_hash TEXT PRIMARY KEY,
+ user_code TEXT NOT NULL UNIQUE,
+ label TEXT NOT NULL DEFAULT '',
+ remote_addr TEXT NOT NULL DEFAULT '',
+ address TEXT NOT NULL DEFAULT '',
+ state TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL
+);`,
+ // The issued credential. Only the hash is stored — a console database
+ // that leaks must not hand out working CLI tokens.
+ `CREATE TABLE IF NOT EXISTS cli_tokens (
+ token_hash TEXT PRIMARY KEY,
+ address TEXT NOT NULL,
+ label TEXT NOT NULL DEFAULT '',
+ remote_addr TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ last_used INTEGER NOT NULL DEFAULT 0
+);`,
+ `CREATE INDEX IF NOT EXISTS cli_tokens_address ON cli_tokens (address);`,
+ } {
+ if _, err := s.db.Exec(stmt); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// 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) {
+ b := make([]byte, userCodeLen)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ out := make([]byte, 0, userCodeLen+1)
+ for i, v := range b {
+ if i == userCodeLen/2 {
+ out = append(out, '-')
+ }
+ // Modulo bias over a 30-symbol alphabet is at most 0.4 bits across the
+ // whole code; irrelevant against a rate-limited ten-minute window, and
+ // rejection sampling here would only add a loop to read wrong.
+ out = append(out, userCodeAlphabet[int(v)%len(userCodeAlphabet)])
+ }
+ return string(out), nil
+}
+
+// NormalizeUserCode is what the approval form does to whatever a human typed:
+// upper-case, dashes and spaces dropped, then re-grouped. "wxyz 4823",
+// "wxyz-4823" and "WXYZ4823" are the same code.
+func NormalizeUserCode(in string) string {
+ var b strings.Builder
+ for _, r := range strings.ToUpper(strings.TrimSpace(in)) {
+ if r == '-' || r == ' ' || r == '\t' {
+ continue
+ }
+ b.WriteRune(r)
+ }
+ s := b.String()
+ if len(s) != userCodeLen {
+ return s // wrong length: hand it back and let the lookup miss
+ }
+ return s[:userCodeLen/2] + "-" + s[userCodeLen/2:]
+}
+
+// NewCLIAuth opens a login attempt: a typed user code for the human, a device
+// code for the CLI. label is what the CLI calls itself (user@host) and
+// remoteAddr where it dialled from — both are shown on the approval page so a
+// human can tell their own terminal from somebody else's.
+//
+// Housekeeping on the write path, the same idiom handleContinue uses: expired
+// requests and tokens are dropped here rather than by a sweeper nobody runs.
+func (s *Store) NewCLIAuth(label, remoteAddr string, now time.Time) (userCode, deviceCode string, err error) {
+ s.purgeCLIAuth(now)
+ deviceCode, err = randomToken()
+ if err != nil {
+ return "", "", err
+ }
+ // One retry is enough: a collision on 39 bits of live codes means the
+ // second draw is astronomically unlikely to collide too.
+ for attempt := 0; attempt < 2; attempt++ {
+ userCode, err = newUserCode()
+ if err != nil {
+ return "", "", err
+ }
+ _, err = s.db.Exec(`INSERT INTO cli_auth_requests
+(code_hash, user_code, label, remote_addr, state, created_at, expires_at)
+VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ hashToken(deviceCode), userCode, label, remoteAddr, CLIAuthPending,
+ now.Unix(), now.Add(CLIAuthTTL).Unix())
+ if err == nil {
+ return userCode, deviceCode, nil
+ }
+ if !strings.Contains(err.Error(), "UNIQUE") {
+ return "", "", err
+ }
+ }
+ return "", "", fmt.Errorf("console: could not mint a free login code")
+}
+
+// purgeCLIAuth drops what has aged out. Best-effort: losing a sweep must
+// never fail the login it is housekeeping for.
+func (s *Store) purgeCLIAuth(now time.Time) {
+ 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())
+}
+
+// CLIAuthByUserCode resolves a typed code to the request it names, for the
+// approval page. Expired and already-decided requests are "no such code" —
+// the page must never offer to approve something that cannot be approved.
+func (s *Store) CLIAuthByUserCode(userCode string, now time.Time) (*CLIAuthRequest, error) {
+ var r CLIAuthRequest
+ var created, expires int64
+ var state string
+ err := s.db.QueryRow(`SELECT user_code, label, remote_addr, state, created_at, expires_at
+FROM cli_auth_requests WHERE user_code = ?`, NormalizeUserCode(userCode)).
+ Scan(&r.UserCode, &r.Label, &r.RemoteAddr, &state, &created, &expires)
+ if err == sql.ErrNoRows {
+ return nil, errors.New("console: no such login code")
+ }
+ if err != nil {
+ return nil, err
+ }
+ if state != CLIAuthPending || now.Unix() > expires {
+ return nil, errors.New("console: that login code has expired or was already used")
+ }
+ r.CreatedAt = time.Unix(created, 0).UTC()
+ r.ExpiresAt = time.Unix(expires, 0).UTC()
+ return &r, nil
+}
+
+// ApproveCLIAuth records that address approved the request. The token is NOT
+// minted here: it is minted when the CLI collects it (PollCLIAuth), so no
+// bearer credential is ever at rest waiting to be picked up.
+func (s *Store) ApproveCLIAuth(userCode, address string, now time.Time) error {
+ return s.decideCLIAuth(userCode, address, CLIAuthApproved, now)
+}
+
+// DenyCLIAuth is the "that wasn't me" button. A denied request is final:
+// approving it afterwards is refused, so a mis-click cannot be walked back
+// into a credential by whoever is holding the terminal.
+func (s *Store) DenyCLIAuth(userCode string, now time.Time) error {
+ return s.decideCLIAuth(userCode, "", CLIAuthDenied, now)
+}
+
+func (s *Store) decideCLIAuth(userCode, address, state string, now time.Time) error {
+ res, err := s.db.Exec(`UPDATE cli_auth_requests SET state = ?, address = ?
+WHERE user_code = ? AND state = ? AND expires_at >= ?`,
+ state, address, NormalizeUserCode(userCode), CLIAuthPending, now.Unix())
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return errors.New("console: that login code has expired or was already used")
+ }
+ return nil
+}
+
+// PollCLIAuth is what `carlos auth login` calls in a loop. On approval it
+// 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) {
+ if deviceCode == "" {
+ return CLIAuthUnknown, "", nil
+ }
+ tx, err := s.db.Begin()
+ if err != nil {
+ return "", "", err
+ }
+ defer tx.Rollback()
+ hash := hashToken(deviceCode)
+ var storedHash, state, address, 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
+ }
+ if err != nil {
+ 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
+ }
+ if now.Unix() > expires {
+ return CLIAuthExpired, "", nil
+ }
+ switch state {
+ case CLIAuthPending:
+ return CLIAuthPending, "", nil
+ case CLIAuthDenied:
+ return CLIAuthDenied, "", nil
+ case CLIAuthApproved:
+ default:
+ return CLIAuthUnknown, "", nil
+ }
+ raw, err := randomToken()
+ if err != nil {
+ 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
+ }
+ // 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
+ }
+ if err := tx.Commit(); err != nil {
+ return "", "", err
+ }
+ return CLIAuthApproved, raw, nil
+}
+
+// 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.
+func (s *Store) CLITokenAddress(rawToken string, now time.Time) (string, bool) {
+ if rawToken == "" {
+ return "", false
+ }
+ hash := hashToken(rawToken)
+ var storedHash, addr 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)
+ if err != nil {
+ return "", false
+ }
+ if subtle.ConstantTimeCompare([]byte(storedHash), []byte(hash)) != 1 {
+ return "", false
+ }
+ if now.Unix() > expires {
+ return "", 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 addr, true
+}
+
+// CLITokens lists one address's live CLI credentials, newest first — the
+// listing the tokens page renders so an owner can see, and kill, every
+// terminal that speaks as them.
+func (s *Store) CLITokens(address string) ([]CLIToken, error) {
+ rows, err := s.db.Query(`SELECT token_hash, address, label, created_at, expires_at, last_used
+FROM cli_tokens WHERE address = ? ORDER BY created_at DESC, token_hash`, address)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []CLIToken
+ for rows.Next() {
+ var t CLIToken
+ var created, expires, used int64
+ if err := rows.Scan(&t.ID, &t.Address, &t.Label, &created, &expires, &used); err != nil {
+ return nil, err
+ }
+ t.CreatedAt = time.Unix(created, 0).UTC()
+ t.ExpiresAt = time.Unix(expires, 0).UTC()
+ if used != 0 {
+ t.LastUsed = time.Unix(used, 0).UTC()
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+// RevokeCLIToken kills one token by its listing id, scoped to its owner: an
+// id is not a secret, so the address is what makes this safe.
+func (s *Store) RevokeCLIToken(address, id string) error {
+ res, err := s.db.Exec(`DELETE FROM cli_tokens WHERE token_hash = ? AND address = ?`, id, address)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return errors.New("console: no such token")
+ }
+ return nil
+}
+
+// RevokeCLITokenValue kills a token by the token itself — what `carlos auth
+// logout` sends, so signing out of a terminal actually retires the credential
+// rather than just forgetting it locally.
+func (s *Store) RevokeCLITokenValue(rawToken string) error {
+ _, err := s.db.Exec(`DELETE FROM cli_tokens WHERE token_hash = ?`, hashToken(rawToken))
+ return err
+}
diff --git a/internal/console/authstore_test.go b/internal/console/authstore_test.go
new file mode 100644
index 000000000..504a2a262
--- /dev/null
+++ b/internal/console/authstore_test.go
@@ -0,0 +1,199 @@
+package console
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestCLIAuthApproveThenPollIssuesOneToken(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+
+ userCode, deviceCode, err := s.NewCLIAuth("paul@laptop", "192.0.2.7", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(userCode) != userCodeLen+1 || !strings.Contains(userCode, "-") {
+ t.Fatalf("user code %q: want the grouped XXXX-XXXX shape", userCode)
+ }
+ if strings.ContainsAny(userCode, "01ILO") {
+ t.Fatalf("user code %q contains an ambiguous character", userCode)
+ }
+ if deviceCode == userCode || len(deviceCode) < 40 {
+ t.Fatalf("device code %q must be its own high-entropy secret", deviceCode)
+ }
+
+ req, err := s.CLIAuthByUserCode(userCode, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if req.Label != "paul@laptop" || req.RemoteAddr != "192.0.2.7" {
+ t.Fatalf("request = %+v, want the label and address the CLI arrived with", req)
+ }
+
+ // 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 != "" {
+ 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)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status != CLIAuthApproved || raw == "" {
+ t.Fatalf("poll after approval = %q %q, want a token", status, raw)
+ }
+
+ // The token is bound to the address that approved it.
+ addr, ok := s.CLITokenAddress(raw, now)
+ if !ok || addr != "paul@keymail.dev" {
+ t.Fatalf("CLITokenAddress = %q %v, want paul@keymail.dev", addr, ok)
+ }
+
+ // The request is single-use: a replayed device code issues nothing.
+ 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 {
+ t.Fatal("the user code still resolves after the token was collected")
+ }
+}
+
+func TestCLIAuthDenyIsFinal(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ userCode, deviceCode, err := s.NewCLIAuth("paul@laptop", "192.0.2.7", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.DenyCLIAuth(userCode, now); err != nil {
+ t.Fatal(err)
+ }
+ 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.
+ if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", now); err == nil {
+ t.Fatal("a denied request was approvable")
+ }
+}
+
+func TestCLIAuthExpires(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ userCode, deviceCode, err := s.NewCLIAuth("paul@laptop", "192.0.2.7", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ later := now.Add(CLIAuthTTL + time.Second)
+ if _, err := s.CLIAuthByUserCode(userCode, later); err == nil {
+ t.Fatal("an expired user code still resolves for approval")
+ }
+ 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 != "" {
+ t.Fatalf("poll after expiry = %q %q, want expired", status, tok)
+ }
+}
+
+// An approval that is never collected must not mint a token that outlives the
+// ten-minute window: the human approved a CLI that was already gone.
+func TestCLIAuthApprovedButUncollectedExpires(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ userCode, deviceCode, err := s.NewCLIAuth("paul@laptop", "192.0.2.7", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ 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 != "" {
+ t.Fatalf("late collection = %q %q, want expired with no token", status, tok)
+ }
+}
+
+func TestCLITokenLifecycle(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ mint := func(label string) string {
+ t.Helper()
+ userCode, deviceCode, err := s.NewCLIAuth(label, "192.0.2.7", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.ApproveCLIAuth(userCode, "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ _, raw, err := s.PollCLIAuth(deviceCode, now)
+ if err != nil || raw == "" {
+ t.Fatalf("mint %s: %v", label, err)
+ }
+ return raw
+ }
+ laptop := mint("paul@laptop")
+ desktop := mint("paul@desktop")
+
+ toks, err := s.CLITokens("paul@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(toks) != 2 {
+ t.Fatalf("CLITokens = %d entries, want 2", len(toks))
+ }
+ for _, tok := range toks {
+ if tok.ID == "" || tok.Address != "paul@keymail.dev" {
+ t.Fatalf("token row %+v is missing an id or address", tok)
+ }
+ if strings.Contains(laptop, tok.ID) || strings.Contains(desktop, tok.ID) {
+ t.Fatal("the listing id contains the bearer secret")
+ }
+ }
+ // Somebody else's listing is empty, and they cannot revoke what is not theirs.
+ if other, err := s.CLITokens("bob@keymail.dev"); err != nil || len(other) != 0 {
+ t.Fatalf("CLITokens(bob) = %v %v, want empty", other, err)
+ }
+ if err := s.RevokeCLIToken("bob@keymail.dev", toks[0].ID); err == nil {
+ t.Fatal("bob revoked paul's token")
+ }
+ if _, ok := s.CLITokenAddress(laptop, now); !ok {
+ t.Fatal("the laptop token stopped working after a refused revocation")
+ }
+
+ // Revoking by id (the console page) and by value (carlos auth logout).
+ var laptopID string
+ for _, tok := range toks {
+ if tok.Label == "paul@laptop" {
+ laptopID = tok.ID
+ }
+ }
+ if err := s.RevokeCLIToken("paul@keymail.dev", laptopID); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := s.CLITokenAddress(laptop, now); ok {
+ t.Fatal("a revoked token still authenticates")
+ }
+ if err := s.RevokeCLITokenValue(desktop); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := s.CLITokenAddress(desktop, now); ok {
+ t.Fatal("a token revoked by value still authenticates")
+ }
+
+ // Expiry is enforced on use, not just on listing.
+ fresh := mint("paul@ci")
+ if _, ok := s.CLITokenAddress(fresh, now.Add(CLITokenTTL+time.Second)); ok {
+ t.Fatal("an expired token still authenticates")
+ }
+ if _, ok := s.CLITokenAddress("not-a-token", now); ok {
+ t.Fatal("a made-up token authenticates")
+ }
+ if _, ok := s.CLITokenAddress("", now); ok {
+ t.Fatal("an empty token authenticates")
+ }
+}
diff --git a/internal/console/store.go b/internal/console/store.go
index 7fb7cb82f..a92fb3ba8 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -186,7 +186,12 @@ func (s *Store) migrate() error {
return err
}
}
- return s.migrateAppsKey()
+ if err := s.migrateAppsKey(); err != nil {
+ return err
+ }
+ // CLI identity's own tables (authstore.go), kept out of the list above so
+ // the feature is one file to read and one file to remove.
+ return s.migrateCLIAuth()
}
// migrateAppsKey moves the apps table from PRIMARY KEY(app) — a GLOBAL name