From da19dcf48079d3a204c4d90dd06b25a4b76aa653 Mon Sep 17 00:00:00 2001 From: bcmmbaga Date: Wed, 22 Jul 2026 20:08:48 +0300 Subject: [PATCH] prevent concurrent JWT reuse --- management/server/auth/session.go | 32 +++++++--------- management/server/auth/session_test.go | 51 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/management/server/auth/session.go b/management/server/auth/session.go index 7621a1c10..b4c3b612e 100644 --- a/management/server/auth/session.go +++ b/management/server/auth/session.go @@ -7,9 +7,6 @@ import ( "errors" "fmt" "time" - - "github.com/eko/gocache/lib/v4/cache" - "github.com/eko/gocache/lib/v4/store" ) const ( @@ -22,12 +19,17 @@ var ( ErrTokenExpired = errors.New("JWT expired") ) -type SessionStore struct { - cache *cache.Cache[string] +// TokenCache atomically records used JWTs until their expiration. +type TokenCache interface { + SetNX(ctx context.Context, key, value string, ttl time.Duration) (bool, error) } -func NewSessionStore(cacheStore store.StoreInterface) *SessionStore { - return &SessionStore{cache: cache.New[string](cacheStore)} +type SessionStore struct { + cache TokenCache +} + +func NewSessionStore(cacheStore TokenCache) *SessionStore { + return &SessionStore{cache: cacheStore} } // RegisterToken records a JWT until its exp time and rejects reuse. @@ -38,19 +40,13 @@ func (s *SessionStore) RegisterToken(ctx context.Context, token string, expiresA } key := usedTokenKeyPrefix + hashToken(token) - _, err := s.cache.Get(ctx, key) - if err == nil { - return ErrTokenAlreadyUsed - } - - var notFound *store.NotFound - if !errors.As(err, ¬Found) { - return fmt.Errorf("failed to lookup used token entry: %w", err) - } - - if err := s.cache.Set(ctx, key, usedTokenMarker, store.WithExpiration(ttl)); err != nil { + created, err := s.cache.SetNX(ctx, key, usedTokenMarker, ttl) + if err != nil { return fmt.Errorf("failed to store used token entry: %w", err) } + if !created { + return ErrTokenAlreadyUsed + } return nil } diff --git a/management/server/auth/session_test.go b/management/server/auth/session_test.go index 3a7d85f4c..cdc825e06 100644 --- a/management/server/auth/session_test.go +++ b/management/server/auth/session_test.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "testing" "time" @@ -38,6 +39,39 @@ func TestSessionStore_RegisterSameTokenTwiceIsRejected(t *testing.T) { assert.ErrorIs(t, err, ErrTokenAlreadyUsed) } +func TestSessionStore_ConcurrentRegistrationAllowsOneCaller(t *testing.T) { + s := newTestSessionStore(t) + ctx := context.Background() + const attempts = 100 + + start := make(chan struct{}) + results := make(chan error, attempts) + for range attempts { + go func() { + <-start + results <- s.RegisterToken(ctx, "token", time.Now().Add(time.Hour)) + }() + } + close(start) + + succeeded := 0 + alreadyUsed := 0 + for range attempts { + err := <-results + switch { + case err == nil: + succeeded++ + case errors.Is(err, ErrTokenAlreadyUsed): + alreadyUsed++ + default: + require.NoError(t, err) + } + } + + assert.Equal(t, 1, succeeded) + assert.Equal(t, attempts-1, alreadyUsed) +} + func TestSessionStore_RegisterDifferentTokensAreIndependent(t *testing.T) { s := newTestSessionStore(t) ctx := context.Background() @@ -72,6 +106,23 @@ func TestSessionStore_EntryEvictsAtTTLAndAllowsReRegistration(t *testing.T) { require.NoError(t, s.RegisterToken(ctx, token, time.Now().Add(time.Hour))) } +type failingTokenCache struct { + err error +} + +func (f failingTokenCache) SetNX(context.Context, string, string, time.Duration) (bool, error) { + return false, f.err +} + +func TestSessionStore_CacheErrorIsReturned(t *testing.T) { + cacheErr := errors.New("cache unavailable") + s := NewSessionStore(failingTokenCache{err: cacheErr}) + + err := s.RegisterToken(context.Background(), "token", time.Now().Add(time.Hour)) + require.Error(t, err) + assert.ErrorIs(t, err, cacheErr) +} + func TestHashToken_StableAndDoesNotLeak(t *testing.T) { a := hashToken("tokenA") b := hashToken("tokenB")