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
+53 -3
View File
@@ -519,8 +519,8 @@ func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
t.Fatalf("expected 2 refresh tokens in family, got %d", famCount)
}
// Reuse grace window: backdate the original's used_at past the 60s reuse
// grace so the replay below is genuine theft. WITHOUT this, a replay
// Reuse grace window: backdate the original's used_at past the reuse grace
// so the replay below is genuine theft. WITHOUT this, a replay
// moments after rotation is a benign two-tab concurrent refresh and the
// family must NOT be killed.
if _, err := tx.Exec(ctx, `
@@ -573,7 +573,7 @@ func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
}
// TestVerifyRefreshToken_ReplayWithinGrace_IsBenign verifies the reuse
// hardening: a used-token replay WITHIN the 60s grace window (two tabs sharing
// hardening: a used-token replay WITHIN the grace window (two tabs sharing
// one localStorage refresh token both refreshing on load) is a benign
// concurrent refresh — the generic error is returned, but the rotation family
// survives and no refresh_token_reuse alert is raised.
@@ -634,6 +634,56 @@ func TestVerifyRefreshToken_ReplayWithinGrace_IsBenign(t *testing.T) {
}
}
// TestVerifyRefreshToken_GraceBoundary pins the reuse-grace boundary (LOW-2,
// reduced 60s → 20s): a used-token replay just INSIDE the grace window is a
// benign concurrent refresh — the family survives — while a replay just past
// the window is genuine theft and revokes the ENTIRE rotation family. The
// exact boundary is refreshTokenReuseGraceSecs (derived from
// refreshTokenReuseGrace), so this test holds the reduced value honest.
func TestVerifyRefreshToken_GraceBoundary(t *testing.T) {
ctx, tx := testtx.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
original, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
require.NoError(t, err)
_, _, familyID, err := VerifyRefreshToken(ctx, original)
require.NoError(t, err)
require.NotEmpty(t, familyID)
_, err = GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID)
require.NoError(t, err)
backdate := func(secs int64) {
t.Helper()
_, err = tx.Exec(ctx, `
UPDATE refresh_tokens
SET used_at = NOW() - make_interval(secs => $2)
WHERE token_hash = encode(sha256($1::bytea), 'hex')
`, original, secs)
require.NoError(t, err)
}
// Replay just INSIDE the grace window (grace - 1s) → benign: the family
// (used original + descendant) survives and no theft alert is raised.
backdate(refreshTokenReuseGraceSecs - 1)
_, _, _, err = VerifyRefreshToken(ctx, original)
require.Error(t, err, "a within-grace replay must still return the generic error")
require.Contains(t, err.Error(), "invalid or expired")
var famCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount))
require.Equal(t, 2, famCount, "a within-grace replay must NOT kill the rotation family")
// Replay just OUTSIDE the grace window (grace + 1s) → theft: the ENTIRE
// family is revoked (descendant included) and a critical alert is raised.
backdate(refreshTokenReuseGraceSecs + 1)
_, _, _, err = VerifyRefreshToken(ctx, original)
require.Error(t, err, "a post-grace replay must return the generic error")
require.Contains(t, err.Error(), "invalid or expired")
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount))
require.Equal(t, 0, famCount, "a post-grace replay must revoke the ENTIRE rotation family")
}
// TestAccessTokenKilledWithRotationFamily verifies the HIGH 1 fix: an access
// token minted at rotation is bound (family_id claim) to the rotation family,
// so when reuse detection DELETEs the family the access token — which the