283e8f6b46
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>
184 lines
6.0 KiB
Go
184 lines
6.0 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
goredis "github.com/redis/go-redis/v9"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func newFailingSessionRedisClient(t *testing.T) *goredis.Client {
|
|
t.Helper()
|
|
client := goredis.NewClient(&goredis.Options{
|
|
Addr: "127.0.0.1:1",
|
|
MaxRetries: 0,
|
|
DialTimeout: 10 * time.Millisecond,
|
|
ReadTimeout: 10 * time.Millisecond,
|
|
WriteTimeout: 10 * time.Millisecond,
|
|
})
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
return client
|
|
}
|
|
|
|
func TestSessionStoreRotateRefreshSuccess(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))
|
|
require.NoError(t, store.RotateRefresh(ctx, "old-jti", "old-hash", "new-jti", "new-hash", 2*time.Minute))
|
|
|
|
_, err := store.GetRefresh(ctx, "old-jti")
|
|
assert.Error(t, err)
|
|
|
|
value, err := store.GetRefresh(ctx, "new-jti")
|
|
require.NoError(t, err)
|
|
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()})
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
|
|
store := NewSessionStore(client)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, store.SaveRefresh(ctx, "old-jti", "old-hash", time.Minute))
|
|
|
|
err := store.RotateRefresh(ctx, "old-jti", "wrong-hash", "new-jti", "new-hash", 2*time.Minute)
|
|
require.ErrorIs(t, err, ErrRefreshTokenMismatch)
|
|
|
|
value, getErr := store.GetRefresh(ctx, "old-jti")
|
|
require.NoError(t, getErr)
|
|
assert.Equal(t, "old-hash", value)
|
|
|
|
_, getErr = store.GetRefresh(ctx, "new-jti")
|
|
assert.Error(t, getErr)
|
|
}
|
|
|
|
func TestSessionStoreUsesMemoryFallbackWhenRedisUnavailable(t *testing.T) {
|
|
store := NewSessionStore(newFailingSessionRedisClient(t))
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, store.SaveRefresh(ctx, "old-jti", "old-hash", time.Minute))
|
|
value, err := store.GetRefresh(ctx, "old-jti")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "old-hash", value)
|
|
|
|
require.NoError(t, store.RotateRefresh(ctx, "old-jti", "old-hash", "new-jti", "new-hash", time.Minute))
|
|
_, err = store.GetRefresh(ctx, "old-jti")
|
|
assert.Error(t, err)
|
|
value, err = store.GetRefresh(ctx, "new-jti")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "new-hash", value)
|
|
|
|
require.NoError(t, store.Blacklist(ctx, "access-jti", time.Minute))
|
|
revoked, err := store.IsBlacklisted(ctx, "access-jti")
|
|
require.NoError(t, err)
|
|
assert.True(t, revoked)
|
|
}
|
|
|
|
func TestSessionStoreBumpSessionVersion(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()
|
|
|
|
version, err := store.SessionVersion(ctx, 42)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(0), version)
|
|
|
|
version, err = store.BumpSessionVersion(ctx, 42)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(1), version)
|
|
|
|
version, err = store.SessionVersion(ctx, 42)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(1), version)
|
|
}
|
|
|
|
func TestSessionStoreSessionVersionKeepsFallbackBumpWhenRedisLags(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, mr.Set(sessionVersionKey(42), "1"))
|
|
store.fallback.set(sessionVersionKey(42), "3", sessionVersionFallbackTTL)
|
|
|
|
version, err := store.SessionVersion(ctx, 42)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(3), version)
|
|
stored, err := mr.Get(sessionVersionKey(42))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "3", stored)
|
|
}
|