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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user