package services import ( "context" "crypto/sha256" "encoding/hex" "fmt" "time" "github.com/google/uuid" "github.com/redis/go-redis/v9" ) const refreshLockTTL = 30 * time.Second // RefreshLock provides Redis-based locking for refresh token operations // to prevent race conditions when two concurrent refresh requests use the same token. type RefreshLock struct { client *redis.Client } // NewRefreshLock creates a new RefreshLock. If client is nil, AcquireRefreshLock // will always return (true, noop) to allow operation without Redis (graceful degradation). func NewRefreshLock(client *redis.Client) *RefreshLock { return &RefreshLock{client: client} } // AcquireRefreshLock acquires a Redis lock for the given userID+token combination. // Returns (true, releaseFn) if lock acquired; (false, nil) if already held by another request. // releaseFn must be called to release the lock (typically via defer). // Key format: refresh_lock:{userID}:{tokenHash} to avoid storing full token in Redis. func (r *RefreshLock) AcquireRefreshLock(ctx context.Context, userID uuid.UUID, token string) (bool, func()) { if r.client == nil { return true, func() {} } key := fmt.Sprintf("refresh_lock:%s:%s", userID.String(), hashToken(token)) ok, err := r.client.SetNX(ctx, key, "1", refreshLockTTL).Result() if err != nil { return false, nil } if !ok { return false, nil } release := func() { _ = r.client.Del(context.Background(), key).Err() } return true, release } func hashToken(s string) string { h := sha256.Sum256([]byte(s)) return hex.EncodeToString(h[:8]) }