fix: prevent forced re-login from concurrent refresh token rotation

Rotated refresh tokens now stay valid for a 60s grace window so
concurrent refreshes (e.g. multiple browser tabs) each obtain a valid
new pair instead of being logged out. Admin-web additionally serializes
refreshes across tabs via the Web Locks API and reuses tokens already
rotated by another tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 20:55:19 +08:00
parent eb4d870081
commit 283e8f6b46
3 changed files with 158 additions and 43 deletions
@@ -43,6 +43,62 @@ func TestSessionStoreRotateRefreshSuccess(t *testing.T) {
assert.Equal(t, "new-hash", value)
}
func TestSessionStoreRotateRefreshAllowsConcurrentReuseWithinGrace(t *testing.T) {
mr := miniredis.RunT(t)
client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = client.Close() })
store := NewSessionStore(client)
ctx := context.Background()
require.NoError(t, store.SaveRefresh(ctx, "old-jti", "old-hash", time.Minute))
// Tab A rotates first, tab B retries with the same old token within the
// grace window: both must obtain valid new sessions.
require.NoError(t, store.RotateRefresh(ctx, "old-jti", "old-hash", "new-jti-a", "new-hash-a", 2*time.Minute))
require.NoError(t, store.RotateRefresh(ctx, "old-jti", "old-hash", "new-jti-b", "new-hash-b", 2*time.Minute))
valueA, err := store.GetRefresh(ctx, "new-jti-a")
require.NoError(t, err)
assert.Equal(t, "new-hash-a", valueA)
valueB, err := store.GetRefresh(ctx, "new-jti-b")
require.NoError(t, err)
assert.Equal(t, "new-hash-b", valueB)
// A different (wrong) old hash must still be rejected during grace.
err = store.RotateRefresh(ctx, "old-jti", "wrong-hash", "new-jti-c", "new-hash-c", 2*time.Minute)
require.ErrorIs(t, err, ErrRefreshTokenMismatch)
// The rotated old token stays hidden from session lookups during grace.
_, err = store.GetRefresh(ctx, "old-jti")
require.ErrorIs(t, err, ErrRefreshSessionNotFound)
}
func TestMemorySessionStoreRotateAllowsConcurrentReuseWithinGrace(t *testing.T) {
store := NewSessionStore(nil)
ctx := context.Background()
require.NoError(t, store.SaveRefresh(ctx, "old-jti", "old-hash", time.Minute))
require.NoError(t, store.RotateRefresh(ctx, "old-jti", "old-hash", "new-jti-a", "new-hash-a", 2*time.Minute))
require.NoError(t, store.RotateRefresh(ctx, "old-jti", "old-hash", "new-jti-b", "new-hash-b", 2*time.Minute))
_, err := store.GetRefresh(ctx, "old-jti")
assert.Error(t, err)
valueA, err := store.GetRefresh(ctx, "new-jti-a")
require.NoError(t, err)
assert.Equal(t, "new-hash-a", valueA)
valueB, err := store.GetRefresh(ctx, "new-jti-b")
require.NoError(t, err)
assert.Equal(t, "new-hash-b", valueB)
err = store.RotateRefresh(ctx, "old-jti", "wrong-hash", "new-jti-c", "new-hash-c", 2*time.Minute)
require.ErrorIs(t, err, ErrRefreshTokenMismatch)
}
func TestSessionStoreRotateRefreshRejectsMismatch(t *testing.T) {
mr := miniredis.RunT(t)
client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})