fix: refresh-token reuse grace 60s -> 20s (cross-tab coordinated rotation)

The frontend's cross-tab coordination (auth.svelte.ts REFRESH_LOCK_TTL_MS=15s +
20s wait-for-timeout) guarantees only ONE tab rotates and every sibling adopts
the rotated pair, so the only legitimately-arriving replays are same-tick
races (sub-second). The old 60s window handed a stolen refresh token a full
minute of freshness before reuse detection fired; 20s keeps comfortable margin
over the coordination bound while cutting the undetected-theft window to a
third. The ideal fix (kill only when the replay's IP/UA differs) still needs
rotation-origin persistence the locked schema cannot express.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 8d9f72b9f0
commit d0d72d8caf
2 changed files with 65 additions and 12 deletions
+12 -9
View File
@@ -41,16 +41,19 @@ const refreshTokenLifetimeDays = int64(RefreshTokenLifetime / (24 * time.Hour))
// 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
// LOW-2: 20s (was 60s). The frontend's cross-tab coordination
// (frontend/src/lib/stores/auth.svelte.ts: REFRESH_LOCK_TTL_MS = 15s, plus a
// 20s wait-for-timeout) guarantees only ONE tab performs a rotation and every
// sibling tab adopts the rotated pair instead of replaying the old token, so
// the only legitimately-arriving replays are same-tick races — two in-flight
// fetches that crossed before the lock settled, sub-second. The old 60s window
// handed a stolen refresh token up to a full minute of freshness before reuse
// detection fired; 20s keeps comfortable margin over the cross-tab coordination
// bound while cutting the undetected-theft window to a third. 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
// cannot express today.
const refreshTokenReuseGrace = 20 * time.Second
// refreshTokenReuseGraceSecs is the grace window in whole seconds for the SQL
// make_interval(secs => ...) comparison in VerifyRefreshToken's reuse branch.
@@ -667,7 +670,7 @@ func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string,
// 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
// once and replayed past the grace window — 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