Fix F-03: make token renewal atomic

- db/accounts.go: add RenewToken(oldJTI, reason, newJTI,
  accountID, issuedAt, expiresAt) which wraps RevokeToken +
  TrackToken in a single BEGIN/COMMIT transaction; if either
  step fails the whole tx rolls back, so the user is never
  left with neither old nor new token valid
- server.go (handleRenewToken): replace separate RevokeToken +
  TrackToken calls with single RenewToken call; failure now
  returns 500 instead of silently losing revocation
- grpcserver/auth.go (RenewToken): same replacement
- db/db_test.go: TestRenewTokenAtomic verifies old token is
  revoked with correct reason, new token is tracked and not
  revoked, and a second renewal on the already-revoked old
  token returns an error
- AUDIT.md: mark F-03 as fixed
Security: without atomicity a crash/error between revoke and
  track could leave the old token active alongside the new one
  (two live tokens) or revoke the old token without tracking
  the new one (user locked out). The transaction ensures
  exactly one of the two tokens is valid at all times.
This commit is contained in:
2026-03-11 20:24:32 -07:00
parent 462f706f73
commit bf9002a31c
5 changed files with 107 additions and 9 deletions

View File

@@ -326,6 +326,55 @@ func TestPGCredentials(t *testing.T) {
}
}
func TestRenewTokenAtomic(t *testing.T) {
db := openTestDB(t)
acct, err := db.CreateAccount("judy", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
now := time.Now().UTC()
exp := now.Add(time.Hour)
// Set up the old token.
oldJTI := "renew-old-jti"
newJTI := "renew-new-jti"
if err := db.TrackToken(oldJTI, acct.ID, now, exp); err != nil {
t.Fatalf("TrackToken old: %v", err)
}
// RenewToken should atomically revoke old and track new.
if err := db.RenewToken(oldJTI, "renewed", newJTI, acct.ID, now, exp); err != nil {
t.Fatalf("RenewToken: %v", err)
}
// Old token must be revoked.
oldRec, err := db.GetTokenRecord(oldJTI)
if err != nil {
t.Fatalf("GetTokenRecord old: %v", err)
}
if !oldRec.IsRevoked() {
t.Error("old token should be revoked after RenewToken")
}
if oldRec.RevokeReason != "renewed" {
t.Errorf("old token revoke reason = %q, want %q", oldRec.RevokeReason, "renewed")
}
// New token must be tracked and not revoked.
newRec, err := db.GetTokenRecord(newJTI)
if err != nil {
t.Fatalf("GetTokenRecord new: %v", err)
}
if newRec.IsRevoked() {
t.Error("new token should not be revoked")
}
// RenewToken on an already-revoked old token must fail (atomicity guard).
if err := db.RenewToken(oldJTI, "renewed", "other-jti", acct.ID, now, exp); err == nil {
t.Error("expected error when renewing an already-revoked token")
}
}
func TestRevokeAllUserTokens(t *testing.T) {
db := openTestDB(t)
acct, err := db.CreateAccount("ivan", model.AccountTypeHuman, "hash")