package console import ( "path/filepath" "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, 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) 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") } } // 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 never resolves as a service. // // The app-token side is asserted through CLIPrincipal, which is the resolver // that exists — CLIAppToken was deleted once apiInstancePrincipal collapsed // into the shared principal path (service-principals ruling C27). Every // property it used to prove is proved here against its successor: the // binding it reads back, the kind check that stands alone from expiry, a // real expiry still honoured, and the four no-answers (a user token, a // made-up one, an empty one, a revoked one). 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) } p, ok := s.CLIPrincipal(app, now) if !ok || p.Kind != PrincipalService || p.AccountID != 7 || p.App != "tito" { t.Fatalf("CLIPrincipal = %+v %v, want the service principal bound to 7/tito", p, 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 p, ok := s.CLIPrincipal(dated, now); !ok || p.Kind != PrincipalService || p.AccountID != 9 || p.App != "tito" { t.Fatalf("CLIPrincipal(dated) = %+v %v, want the service principal bound to 9/tito", p, ok) } if _, ok := s.CLIPrincipal(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) } // The other direction. CLIPrincipal answers for a user token — it is the // one resolver for every kind now — so the containment it holds is that // a person never reads back as a SERVICE, which is the property the app // gate used to hold by refusing them outright. if p, ok := s.CLIPrincipal(user, now); !ok || p.Kind != PrincipalUser || p.Address != "paul@keymail.dev" { t.Fatalf("CLIPrincipal(user) = %+v %v, want paul as a person", p, ok) } // Unknown and empty tokens answer no at both doors. if _, ok := s.CLIPrincipal("not-a-token", now); ok { t.Fatal("a made-up token resolved to a principal") } if _, ok := s.CLIPrincipal("", now); ok { t.Fatal("an empty token resolved to a principal") } } // 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.CLIPrincipal(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.CLIPrincipal(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.CLIPrincipal(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 p, ok := second.CLIPrincipal(app, now); !ok || p.Kind != PrincipalService || p.AccountID != 7 || p.App != "tito" { t.Fatalf("after reopen CLIPrincipal = %+v %v, want the service principal bound to 7/tito", p, ok) } if _, ok := second.CLITokenAddress(app, now); ok { t.Fatal("after reopen an app token authenticated at a user door") } } // TestCLITokensReportsKind: the listing an owner acts on must say WHAT each // row is. An app token has no expiry (expires_at 0), so a page that reads // only ExpiresAt renders it as 1 Jan 1970 — a credential that reads as // long-expired is one nobody thinks to rotate. Kind and App are what make // the row honest. func TestCLITokensReportsKind(t *testing.T) { s := openTestStore(t) now := time.Now() mintUserToken(t, s, "paul@keymail.dev", now) if _, err := s.NewAppToken(7, "tito", "paul@keymail.dev", "tito signup", now); err != nil { t.Fatal(err) } toks, err := s.CLITokens("paul@keymail.dev") if err != nil { t.Fatal(err) } if len(toks) != 2 { t.Fatalf("CLITokens = %d rows, want the user token and the app token", len(toks)) } var user, app *CLIToken for i := range toks { if toks[i].Kind == cliKindApp { app = &toks[i] } else { user = &toks[i] } } if user == nil || app == nil { t.Fatalf("CLITokens = %+v, want one row of each kind", toks) } if app.App != "tito" || app.AccountID != 7 { t.Errorf("app token row = %+v, want it bound to account 7 / tito", app) } if !app.NeverExpires() { t.Errorf("app token row = %+v, want NeverExpires (expires_at 0 is \"never\")", app) } if user.NeverExpires() { t.Errorf("user token row = %+v, want a real expiry", user) } }