package logic import ( "context" "sync" "time" "github.com/redis/go-redis/v9" "spark-server/internal/pkg/errs" ) // Denylist stores revoked JWT ids until their original expiry. type Denylist interface { Has(ctx context.Context, jti string) (bool, error) Add(ctx context.Context, jti string, ttl time.Duration) error } // MemoryDenylist is process-local. Used in tests and when Redis is not configured. type MemoryDenylist struct { mu sync.Mutex items map[string]int64 // jti → expiry unix nano } func NewMemoryDenylist() *MemoryDenylist { return &MemoryDenylist{items: make(map[string]int64)} } func (d *MemoryDenylist) Has(_ context.Context, jti string) (bool, error) { if jti == "" { return false, nil } now := time.Now().UnixNano() d.mu.Lock() defer d.mu.Unlock() exp, ok := d.items[jti] if !ok { return false, nil } if now >= exp { delete(d.items, jti) return false, nil } return true, nil } func (d *MemoryDenylist) Add(_ context.Context, jti string, ttl time.Duration) error { if jti == "" || ttl <= 0 { return nil } d.mu.Lock() d.items[jti] = time.Now().Add(ttl).UnixNano() d.mu.Unlock() return nil } // RedisDenylist uses SETEX denylist:{jti} with remaining token TTL. type RedisDenylist struct { rdb *redis.Client prefix string } func NewRedisDenylist(rdb *redis.Client, prefix string) *RedisDenylist { if prefix == "" { prefix = "denylist:" } return &RedisDenylist{rdb: rdb, prefix: prefix} } func (d *RedisDenylist) key(jti string) string { return d.prefix + jti } func (d *RedisDenylist) Has(ctx context.Context, jti string) (bool, error) { if d == nil || d.rdb == nil { return false, errs.ErrAvailable.WithInnerMsg("jwt denylist redis not ready") } n, err := d.rdb.Exists(ctx, d.key(jti)).Result() if err != nil { return false, err } return n > 0, nil } func (d *RedisDenylist) Add(ctx context.Context, jti string, ttl time.Duration) error { if d == nil || d.rdb == nil { return errs.ErrAvailable.WithInnerMsg("jwt denylist redis not ready") } if jti == "" || ttl <= 0 { return nil } return d.rdb.Set(ctx, d.key(jti), "1", ttl).Err() }