package auth import ( "context" "crypto/rand" "errors" "fmt" "log" "log/slog" "strings" "sync" "time" "crussell/clock" "crussell/db" "crussell/internal/adminnotify" "github.com/jackc/pgx/v5" "github.com/go-chi/jwtauth/v5" ) var TokenAuth *jwtauth.JWTAuth // RefreshTokenLifetime is how long an issued refresh token stays valid before // it expires (90 days). Minted rows get expires_at = NOW() + RefreshTokenLifetime // (see GenerateRefreshToken / GenerateRefreshTokenInFamily), and the cleanup // jobs that expire refresh-token families and retain revoked tokens // (handlers/scheduling CleanupExpiredRefreshTokens, jobs SweepSquareWebhookEvents) // reference the SAME constant so the SQL window can never drift from the mint. const RefreshTokenLifetime = 90 * 24 * time.Hour // refreshTokenLifetimeDays is RefreshTokenLifetime expressed in whole days, fed // to the SQL make_interval(days => ...) calls in the mint queries. const refreshTokenLifetimeDays = int64(RefreshTokenLifetime / (24 * time.Hour)) // refreshTokenReuseGrace is how long after a rotation a used-token replay is // treated as a BENIGN concurrent refresh (two tabs sharing one refresh token in // localStorage both refreshing on load) instead of theft. A replay inside the // grace window gets the generic error but does NOT kill the rotation family and // does NOT raise the refresh_token_reuse alert — only a replay after the window // has elapsed is treated as theft (see VerifyRefreshToken). // // LOW-2: 60s (was 30s) because a legitimately-rotated token can be replayed // from the SAME device well after the old 30s window when the user's second // tab refreshes on a slower return (e.g. the tab was suspended by the OS and // wakes up >30s after the first tab rotated). The widened grace costs a stolen // token up to an extra 30s of freshness before reuse detection fires — an // acceptable trade-off for not killing a legitimate session. The ideal fix // (only kill when the replay's IP/User-Agent differs from the rotation's) // would need the rotation origin persisted per family, which the locked schema // cannot express today; the widened window is the safe minimum. const refreshTokenReuseGrace = 60 * time.Second // refreshTokenReuseGraceSecs is the grace window in whole seconds for the SQL // make_interval(secs => ...) comparison in VerifyRefreshToken's reuse branch. const refreshTokenReuseGraceSecs = int64(refreshTokenReuseGrace / time.Second) // accessTokenFamilyClaim is the JWT claim that binds an access token to the // refresh-token rotation family it was minted alongside. VerifyToken rejects an // access token whose family_id no longer exists in refresh_tokens, so when // reuse detection DELETEs a family every access token minted by that lineage // dies immediately instead of remaining valid for its 1-hour TTL (HIGH 1). const accessTokenFamilyClaim = "family_id" // AuthResponse is the response structure for login/refresh endpoints. // RefreshToken is a 90-day opaque, DB-hashed, single-use credential; the // client stores it and presents it (Bearer) to POST /api/refresh-token in // exchange for a fresh access token + a rotated refresh token. It is returned // in JSON so the SPA can persist it — omitting it would make the refresh flow // unusable — but it is never logged and never returned by any other endpoint. type AuthResponse struct { Token string `json:"token"` JTI string `json:"jti"` RefreshToken string `json:"refreshToken,omitempty"` } // generateJTI generates a UUID v4 string using crypto/rand func generateJTI() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("failed to generate JTI: %w", err) } b[6] = (b[6] & 0x0f) | 0x40 b[8] = (b[8] & 0x3f) | 0x80 return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil } // RevokeJTI adds a JTI to the revoked set in PostgreSQL. // Returns an error if the operation fails. func RevokeJTI(ctx context.Context, jti string, expiresAt time.Time) error { if db.Conn == nil { return fmt.Errorf("revoke JTI: db.Conn is nil") } tx, err := db.Conn.Begin(ctx) if err != nil { return fmt.Errorf("revoke JTI: begin transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() _, err = tx.Exec(ctx, `INSERT INTO revoked_jtis (jti, expires_at) VALUES ($1, $2) ON CONFLICT (jti) DO NOTHING`, jti, expiresAt) if err != nil { return fmt.Errorf("revoke JTI %s: %w", jti, err) } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("revoke JTI: commit transaction: %w", err) } return nil } // IsJTIRevoked checks if a JTI is in the revoked set via PostgreSQL. // Returns false if the DB is not initialized (unit tests, startup) — treating // the token as valid is the safer default for availability over security during // startup, and revoked checks are quickly re-evaluated on each request. func IsJTIRevoked(ctx context.Context, jti string) bool { if db.Conn == nil { return false } var exists bool err := db.Conn.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM revoked_jtis WHERE jti = $1 AND expires_at > NOW())`, jti).Scan(&exists) if err != nil { return false } return exists } // familyAliveCacheTTL bounds how long a family-alive verdict stays cached. // VerifyToken already runs one DB query per request for the JTI revocation // check; the family-alive check would add a second. Caching confirmed verdicts // for 30s turns that second query into an in-memory lookup for the common case // (MEDIUM-1), cutting auth-path DB amplification in half. The TTL is short so a // killed family is re-observed quickly, and the cache is explicitly invalidated // on every family kill (VerifyRefreshToken reuse branch, LogoutHandler) so // bound access tokens die immediately when theft is detected (HIGH 1). const familyAliveCacheTTL = 30 * time.Second // familyAliveRecheckGrace is how close a cached family-alive verdict must be to // its TTL before verifyFamilyAlive re-validates it against the DB instead of // trusting the cache (Loop B finding 3). The daily refresh-token cleanup // captures the affected families, DELETEs them, commits, and then invalidates // the in-memory cache — a crash between the DELETE commit and the // invalidation leaves the cache warm for up to familyAliveCacheTTL, accepting a // bound access token after its family was killed. Re-validating an ALIVE // verdict within this grace of its expiry bounds that residual window to the // grace itself; a small grace (5s of a 30s TTL) preserves the MEDIUM-1 // query-amplification win — only near-expiry lookups re-query. const familyAliveRecheckGrace = 5 * time.Second // familyAliveCacheMaxEntries bounds the in-memory map so a flood of distinct // family ids cannot grow it without bound. const familyAliveCacheMaxEntries = 10_000 // familyAliveCacheEntry is one cached family-alive verdict. Only DB-CONFIRMED // results are ever stored — a failed query fails open and is never cached, so a // transient outage cannot freeze a stale rejection or admission into the cache. type familyAliveCacheEntry struct { alive bool expires time.Time } // familyAliveCache is a mutex-guarded, bounded cache of family-alive verdicts // keyed by "|" (the family belongs to one user, but the // composite key keeps the verdict aligned with the SQL conjunct). var familyAliveCache struct { mu sync.Mutex m map[string]familyAliveCacheEntry } func init() { familyAliveCache.m = make(map[string]familyAliveCacheEntry) } // familyAliveLookup returns a cached verdict for a family key and whether it is // still fresh, evicting expired entries opportunistically. func familyAliveLookup(key string) (alive bool, ok bool) { e, ok := familyAliveLookupEntry(key) if !ok { return false, false } return e.alive, true } // familyAliveLookupEntry returns the cached verdict entry (with its expiry) for // a family key and whether it is still fresh, evicting expired entries // opportunistically. Unlike familyAliveLookup it hands the caller the entry so // verifyFamilyAlive can re-validate a near-expiry ALIVE verdict against the DB // (Loop B finding 3 — the residual crash window of the refresh-token cleanup's // post-commit cache invalidation); familyAliveLookup stays as the simple // (alive, ok) accessor used by the tests. func familyAliveLookupEntry(key string) (familyAliveCacheEntry, bool) { familyAliveCache.mu.Lock() defer familyAliveCache.mu.Unlock() e, ok := familyAliveCache.m[key] if !ok { return familyAliveCacheEntry{}, false } if clock.Now().After(e.expires) { delete(familyAliveCache.m, key) return familyAliveCacheEntry{}, false } return e, true } // familyAliveStore records a DB-confirmed verdict, evicting expired entries // and then the oldest live entry when the cache is at capacity. func familyAliveStore(key string, alive bool) { familyAliveCache.mu.Lock() defer familyAliveCache.mu.Unlock() now := clock.Now() e := familyAliveCacheEntry{alive: alive, expires: now.Add(familyAliveCacheTTL)} if len(familyAliveCache.m) >= familyAliveCacheMaxEntries { for k, ce := range familyAliveCache.m { if now.After(ce.expires) { delete(familyAliveCache.m, k) } } } if len(familyAliveCache.m) >= familyAliveCacheMaxEntries { var oldestKey string var oldestAt time.Time for k, ce := range familyAliveCache.m { if oldestKey == "" || ce.expires.Before(oldestAt) { oldestKey, oldestAt = k, ce.expires } } delete(familyAliveCache.m, oldestKey) } familyAliveCache.m[key] = e } // InvalidateFamilyAlive drops every cached verdict for a family so the next // VerifyToken re-queries the DB. Called whenever a rotation family is deleted // (refresh-token reuse kill, logout) so bound access tokens die on their next // verification instead of riding the cache TTL (HIGH 1). func InvalidateFamilyAlive(familyID string) { InvalidateFamilyAliveBatch([]string{familyID}) } // InvalidateFamilyAliveBatch drops every cached family-alive verdict for a set // of families so the next VerifyToken re-queries the DB. Called wherever // rotation families are deleted — the reuse kill, logout, and the scheduled // cleanup of expired refresh tokens (handlers/scheduling/scheduled-cleanup.go) // — so access tokens bound to a family whose last member was deleted die on // their next verification instead of riding the familyAliveCacheTTL (LOW 6 / // finding 3). Empty and blank family ids are skipped. func InvalidateFamilyAliveBatch(familyIDs []string) { if len(familyIDs) == 0 { return } familyAliveCache.mu.Lock() defer familyAliveCache.mu.Unlock() for _, familyID := range familyIDs { if familyID == "" { continue } for k := range familyAliveCache.m { if strings.HasPrefix(k, familyID+"|") { delete(familyAliveCache.m, k) } } } } // InvalidateFamilyAliveByUser drops every cached family-alive verdict for a // user, so access tokens bound to ANY of the user's rotation families are // re-checked against the DB on their next verification. Called when a user's // credentials die wholesale — a password change deletes every refresh token // the user holds, and GDPR erasure does the same inside anonymize_user() / // delete_guest_user() — so killed families' access tokens die immediately // instead of riding the familyAliveCacheTTL (LOW 5). func InvalidateFamilyAliveByUser(userID string) { if userID == "" { return } familyAliveCache.mu.Lock() defer familyAliveCache.mu.Unlock() suffix := "|" + userID for k := range familyAliveCache.m { if strings.HasSuffix(k, suffix) { delete(familyAliveCache.m, k) } } } // CleanupRevokedJTIs removes expired entries from PostgreSQL and returns the count of deleted rows. func CleanupRevokedJTIs(ctx context.Context) (int, error) { if db.Conn == nil { return 0, nil } tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("WARN: Failed to begin transaction for JTI cleanup: %v", err) return 0, err } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() tag, err := tx.Exec(ctx, `DELETE FROM revoked_jtis WHERE expires_at < NOW()`) if err != nil { log.Printf("WARN: Failed to cleanup revoked JTIs: %v", err) return 0, err } if err := tx.Commit(ctx); err != nil { log.Printf("WARN: Failed to commit transaction for JTI cleanup: %v", err) return 0, err } return int(tag.RowsAffected()), nil } func InitJWT(secret string) { TokenAuth = jwtauth.New("HS256", []byte(secret), nil) } // GenerateToken creates a JWT with user_id, role, and a unique jti claim. // Returns the token string, the JTI, and any error. The token carries NO // family_id claim, so it is exempt from the family-alive check in VerifyToken — // kept for callers minting transient/test tokens. Production login and refresh // paths use GenerateTokenForFamily so a killed rotation family cannot keep an // access token alive (HIGH 1). func GenerateToken(userID string, role string) (string, string, error) { return GenerateTokenForFamily(userID, role, "") } // GenerateTokenForFamily mints an access token carrying a family_id claim that // binds it to a refresh-token rotation family. VerifyToken rejects a token whose // family_id no longer exists in refresh_tokens, so when reuse detection DELETEs // the whole family (VerifyRefreshToken), every access token minted by that // lineage dies immediately instead of remaining valid for its 1-hour TTL. // familyID == "" mints an unbound token (same as GenerateToken). func GenerateTokenForFamily(userID string, role string, familyID string) (string, string, error) { jti, err := generateJTI() if err != nil { return "", "", err } claims := map[string]any{ "user_id": userID, "role": role, "jti": jti, "exp": clock.Now().Add(1 * time.Hour).Unix(), // 1 hour } if familyID != "" { claims[accessTokenFamilyClaim] = familyID } _, tokenString, err := TokenAuth.Encode(claims) return tokenString, jti, err } // VerifyToken validates JWT and returns user_id, role, and jti func VerifyToken(tokenString string, ctx context.Context) (userID string, role string, jti string, err error) { token, err := jwtauth.VerifyToken(TokenAuth, tokenString) if err != nil { return "", "", "", err } var uidVal any if err := token.Get("user_id", &uidVal); err != nil { return "", "", "", fmt.Errorf("invalid user_id claim") } userID, ok := uidVal.(string) if !ok { return "", "", "", fmt.Errorf("invalid user_id claim") } var roleVal any if err := token.Get("role", &roleVal); err != nil { return "", "", "", fmt.Errorf("invalid role claim") } role, ok = roleVal.(string) if !ok { return "", "", "", fmt.Errorf("invalid role claim") } var jtiVal any if err := token.Get("jti", &jtiVal); err != nil { return "", "", "", fmt.Errorf("invalid jti claim") } jti, ok = jtiVal.(string) if !ok || jti == "" { return "", "", "", fmt.Errorf("invalid jti claim") } if IsJTIRevoked(ctx, jti) { return "", "", "", fmt.Errorf("token revoked") } // HIGH 1: reject an access token bound (family_id claim) to a rotation // family that reuse detection has killed. A token minted at rotation carries // family_id; when that family no longer exists in refresh_tokens the token is // dead even though its JTI was never revoked — closing the up-to-1-hour // window where a stolen refresh token's freshly-minted access token could // still hit money endpoints (gift-card buy, saved-card booking payment, tip). if err := verifyFamilyAlive(ctx, token, userID); err != nil { return "", "", "", err } return userID, role, jti, nil } // verifyFamilyAlive rejects access tokens bound to a rotation family that no // longer exists in refresh_tokens. A token WITHOUT a family_id claim is unbound // (minted via GenerateToken — tests/legacy callers) and passes. DB-confirmed // verdicts are cached for familyAliveCacheTTL (MEDIUM-1) so VerifyToken does // not run a second query per request; the cache is invalidated on family kills // so a killed family's access tokens die on their next verification (HIGH 1). // On a live-DB query error the check FAILS OPEN with a WARN log — matching // IsJTIRevoked — because genuine theft is already handled by the family kill in // VerifyRefreshToken's reuse branch, and a transient DB error must not turn // into a total 401 outage for every authenticated request. // // Loop B finding 3 (crash-safety residual window): an ALIVE verdict within // familyAliveRecheckGrace of its TTL is re-validated against the DB. The daily // refresh-token cleanup (handlers/scheduling/scheduled-cleanup.go // CleanupExpiredRefreshTokens) invalidates the family-alive cache AFTER its // DELETE commits, so a crash between the commit and the invalidation leaves the // cache warm for the rest of the TTL — a family killed by that missed // invalidation would otherwise keep admitting its bound access tokens. The // near-expiry re-check closes the gap to the grace window. func verifyFamilyAlive(ctx context.Context, token jwtClaimGetter, userID string) error { var familyVal any if err := token.Get(accessTokenFamilyClaim, &familyVal); err != nil { return nil } familyID, ok := familyVal.(string) if !ok || familyID == "" { return nil } if db.Conn == nil { return nil } key := familyID + "|" + userID if e, cached := familyAliveLookupEntry(key); cached { if !e.alive { return fmt.Errorf("token revoked") } // A fresh verdict is trusted (the query-amplification win); only a // near-expiry ALIVE verdict falls through to the DB re-check below. if clock.Now().Before(e.expires.Add(-familyAliveRecheckGrace)) { return nil } } var exists bool err := db.Conn.QueryRow(ctx, `SELECT EXISTS( SELECT 1 FROM refresh_tokens WHERE family_id = $1 AND user_id = $2 )`, familyID, userID).Scan(&exists) if err != nil { slog.Warn("family-alive check failed — failing open (access token admitted)", "family_id", familyID, "err", err) return nil } familyAliveStore(key, exists) if !exists { return fmt.Errorf("token revoked") } return nil } // FamilyIDFromToken returns the access token's family_id claim, or "" when the // token carries none (unbound — test/legacy minting via GenerateToken). It only // DECODES the token without re-verifying the signature; callers must only use // it on a token that already passed VerifyToken (e.g. RequireAuth middleware). // Used by LogoutHandler to scope refresh-token revocation to the presented // session's rotation family (LOW-1). func FamilyIDFromToken(tokenString string) string { if TokenAuth == nil || tokenString == "" { return "" } decoded, err := TokenAuth.Decode(tokenString) if err != nil { return "" } var fv any if err := decoded.Get(accessTokenFamilyClaim, &fv); err != nil { return "" } familyID, _ := fv.(string) return familyID } // jwtClaimGetter is the minimal subset of jwt.Token needed to read a claim // (the token returned by jwtauth.VerifyToken). Kept as an interface so the // lestrrat-go/jwx dependency stays out of this file's imports. type jwtClaimGetter interface { Get(string, interface{}) error } // generateRefreshTokenString creates a cryptographically random opaque refresh token func generateRefreshTokenString() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("failed to generate refresh token: %w", err) } return fmt.Sprintf("%x", b), nil } // GenerateRefreshToken creates a refresh token stored in the database. Returns // the opaque token string to return to the client plus the id of the rotation // family the new row was created in (so the caller can bind the matching access // token to it via GenerateTokenForFamily — see HIGH 1). func GenerateRefreshToken(ctx context.Context, userID string, role string) (string, string, error) { token, err := generateRefreshTokenString() if err != nil { return "", "", err } // Store hashed version in DB with the shared RefreshTokenLifetime expiry query := ` INSERT INTO refresh_tokens (user_id, token_hash, role, family_id, expires_at) VALUES ($1, encode(sha256($2::bytea), 'hex'), $3, gen_random_uuid(), NOW() + make_interval(days => $4)) RETURNING id, family_id` tx, err := db.Conn.Begin(ctx) if err != nil { return "", "", fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var tokenID int64 var familyID string err = tx.QueryRow(ctx, query, userID, token, role, refreshTokenLifetimeDays).Scan(&tokenID, &familyID) if err != nil { return "", "", fmt.Errorf("failed to store refresh token: %w", err) } if err := tx.Commit(ctx); err != nil { return "", "", fmt.Errorf("failed to commit transaction: %w", err) } return token, familyID, nil } // GenerateRefreshTokenInFamily creates a refresh token in the SAME rotation // family as its parent (the family_id returned by VerifyRefreshToken). Rotation // must mint the descendant in the parent's family so a replayed (already-used) // ancestor can revoke the ENTIRE lineage — the descendant included — instead of // leaving a fresh 90-day token alive after theft is detected. func GenerateRefreshTokenInFamily(ctx context.Context, userID string, role string, familyID string) (string, error) { token, err := generateRefreshTokenString() if err != nil { return "", err } // Store hashed version in DB with the shared RefreshTokenLifetime expiry, in the given family query := ` INSERT INTO refresh_tokens (user_id, token_hash, role, family_id, expires_at) VALUES ($1, encode(sha256($2::bytea), 'hex'), $3, $4, NOW() + make_interval(days => $5)) RETURNING id` tx, err := db.Conn.Begin(ctx) if err != nil { return "", fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var tokenID int64 err = tx.QueryRow(ctx, query, userID, token, role, familyID, refreshTokenLifetimeDays).Scan(&tokenID) if err != nil { return "", fmt.Errorf("failed to store refresh token: %w", err) } if err := tx.Commit(ctx); err != nil { return "", fmt.Errorf("failed to commit transaction: %w", err) } return token, nil } // VerifyRefreshToken checks a refresh token and returns user details if valid. // The token is consumed (marked used) upon successful verification — rotation — // and its family_id is returned so the caller can mint the descendant in the // SAME family. If an ALREADY-ROTATED token is presented again after the // refreshTokenReuseGrace window (a replay: the attacker rotated it, then the // victim replayed it), the entire rotation family is revoked (the descendant // minted at rotation dies too, and so does every access token bound to the // family — HIGH 1) and a critical admin notification (reason // 'refresh_token_reuse') is raised. A replay WITHIN the grace window is a // benign concurrent refresh (two tabs sharing one localStorage refresh token // refreshing on load): it gets the generic error but kills NOTHING and raises // NO alert, so the legitimately-rotated session survives. The caller always gets // the generic "invalid or expired refresh token" error so reuse is never leaked. func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string, role string, familyID string, err error) { query := ` UPDATE refresh_tokens SET used_at = NOW() WHERE token_hash = encode(sha256($1::bytea), 'hex') AND expires_at > NOW() AND NOT revoked AND used_at IS NULL RETURNING user_id, role, family_id` tx, err := db.Conn.Begin(ctx) if err != nil { return "", "", "", fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() err = tx.QueryRow(ctx, query, tokenString).Scan(&userID, &role, &familyID) if err == nil { // Rotation: the token is marked used (kept in the row) so a later // replay can be detected, and its family_id is returned. if err := tx.Commit(ctx); err != nil { return "", "", "", fmt.Errorf("failed to commit transaction: %w", err) } return userID, role, familyID, nil } if !errors.Is(err, pgx.ErrNoRows) { return "", "", "", fmt.Errorf("failed to verify refresh token: %w", err) } // The rotation UPDATE matched nothing: the token is expired, revoked, or // never issued — OR it was already used (replayed). A used token replayed // AFTER the grace window is theft: the descendant minted at rotation would // otherwise stay valid for 90 days. A used token replayed WITHIN the grace // window is a benign concurrent refresh (two tabs, one shared refresh token) // and falls through to the generic error below — no family kill, no alert. var reusedUserID, reusedFamilyID string reuseErr := tx.QueryRow(ctx, ` SELECT user_id, family_id FROM refresh_tokens WHERE token_hash = encode(sha256($1::bytea), 'hex') AND used_at IS NOT NULL AND used_at < NOW() - make_interval(secs => $2) `, tokenString, refreshTokenReuseGraceSecs).Scan(&reusedUserID, &reusedFamilyID) if reuseErr == nil { // (i) Revoke the ENTIRE family — the reused token and every descendant. if _, err := tx.Exec(ctx, `DELETE FROM refresh_tokens WHERE family_id = $1`, reusedFamilyID); err != nil { slog.Error("CRITICAL: refresh token reuse detected but family revocation failed", "userID", reusedUserID, "familyID", reusedFamilyID, "err", err) } else { // The family is gone — drop its cached verdict so bound access // tokens die on their next verification instead of riding the // family-alive cache TTL (HIGH 1). InvalidateFamilyAlive(reusedFamilyID) } // (ii) Surface the theft in the admin notification centre. The NOT // EXISTS guard keeps ONE alert per reused family until an admin // acknowledges it — mirroring insertCriticalPaymentNotification — and // the GLOBAL cap (adminnotify.MaxUnacknowledgedCriticalLogs) bounds the // unacknowledged 'refresh_token_reuse' queue ATOMICALLY (Round 2 Loop B // finding 1): without it a register-botnet — N accounts, each rotated // once and replayed past the 60s grace — could bury the single-operator // notification centre under unbounded alerts. The cap is folded into // the INSERT's WHERE clause (count-then-insert is atomic, closing the // TOCTOU), and the pre-check logs the suppression for operator // visibility. if adminnotify.CriticalLogsCapExceeded(ctx, tx, "refresh_token_reuse") { slog.Error("CRITICAL: refresh token reuse detected but admin alert suppressed — unacknowledged 'refresh_token_reuse' queue at the cap", "userID", reusedUserID, "cap", adminnotify.MaxUnacknowledgedCriticalLogs) } else if _, err := tx.Exec(ctx, ` INSERT INTO admin_notifications (reason, user_id, created_at) SELECT 'refresh_token_reuse', $1, NOW() WHERE NOT EXISTS ( SELECT 1 FROM admin_notifications an WHERE an.reason = 'refresh_token_reuse' AND an.user_id = $1 AND an.acknowledged_at IS NULL ) AND (SELECT COUNT(*) FROM admin_notifications _an WHERE _an.reason = 'refresh_token_reuse' AND _an.acknowledged_at IS NULL) < $2 `, reusedUserID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil { slog.Error("CRITICAL: refresh token reuse detected but admin alert insert failed", "userID", reusedUserID, "err", err) } // Commit the family revocation + alert — NOT the deferred rollback. if err := tx.Commit(ctx); err != nil { return "", "", "", fmt.Errorf("failed to commit transaction: %w", err) } // (iii) CRITICAL log; (iv) generic error — never leak that reuse was seen. slog.Error("CRITICAL: refresh token reuse detected — rotation family revoked", "userID", reusedUserID, "familyID", reusedFamilyID) return "", "", "", fmt.Errorf("invalid or expired refresh token") } if !errors.Is(reuseErr, pgx.ErrNoRows) { return "", "", "", fmt.Errorf("failed to verify refresh token: %w", reuseErr) } // Never-issued / expired / revoked token — indistinguishable from a replay // to the client, as before. return "", "", "", fmt.Errorf("invalid or expired refresh token") }