// Package session is the gateway's in-memory session cache. It maps an opaque // bearer token to the backend account id, falling back to the backend's resolve // endpoint on a miss and caching the result for a bounded TTL. The backend // remains the source of truth (sessions are revoke-only there); the cache only // shortcuts the hot path. package session import ( "context" "sync" "time" ) // Resolver resolves a token to an account id at the backend (the cache miss // path). backendclient.Client satisfies it. type Resolver interface { ResolveSession(ctx context.Context, token string) (string, error) } // Cache resolves session tokens to account ids, caching hits for ttl. type Cache struct { backend Resolver ttl time.Duration max int now func() time.Time mu sync.Mutex entries map[string]entry } type entry struct { userID string expires time.Time } // NewCache constructs a Cache over backend with the given TTL and maximum size. func NewCache(backend Resolver, ttl time.Duration, max int) *Cache { if max <= 0 { max = 1 } return &Cache{ backend: backend, ttl: ttl, max: max, now: func() time.Time { return time.Now() }, entries: make(map[string]entry), } } // Resolve returns the account id for token, consulting the cache first and the // backend on a miss (caching the result). An empty token is rejected by the // backend like any unknown token. func (c *Cache) Resolve(ctx context.Context, token string) (string, error) { if uid, ok := c.lookup(token); ok { return uid, nil } uid, err := c.backend.ResolveSession(ctx, token) if err != nil { return "", err } c.store(token, uid) return uid, nil } // Invalidate drops a token from the cache (e.g. after a revoke). func (c *Cache) Invalidate(token string) { c.mu.Lock() defer c.mu.Unlock() delete(c.entries, token) } // lookup returns a live cached account id for token. func (c *Cache) lookup(token string) (string, bool) { c.mu.Lock() defer c.mu.Unlock() e, ok := c.entries[token] if !ok || !c.now().Before(e.expires) { return "", false } return e.userID, true } // store caches token -> userID, sweeping expired entries and bounding the size. func (c *Cache) store(token, userID string) { c.mu.Lock() defer c.mu.Unlock() if len(c.entries) >= c.max { c.evictLocked() } c.entries[token] = entry{userID: userID, expires: c.now().Add(c.ttl)} } // evictLocked removes expired entries and, if still at capacity, drops arbitrary // entries until below the limit. The caller holds c.mu. func (c *Cache) evictLocked() { now := c.now() for k, e := range c.entries { if !now.Before(e.expires) { delete(c.entries, k) } } for k := range c.entries { if len(c.entries) < c.max { break } delete(c.entries, k) } }