Skip to content

feat: Add OgiriTokenLookupCache SPI for pluggable token caching #26

Description

@mosobande

Problem

Every authenticated request executes getByUserIdAndClient() (DB read) even when the same token was validated milliseconds ago. The existing tokenEqualityCache caches BCrypt comparison results but not the token entity itself.

For high-traffic apps with polling (notifications, feed), this adds 1 unnecessary DB round-trip per request that could be avoided with a short-lived cache.

Per-request DB path (from source)

OgiriTokenAuthenticationFilter.authenticateRequest()
  → tokenService.validToken(token, user, client)
    → getByUserIdAndClient(userId, client)   // DB: SELECT * FROM user_tokens WHERE user_id = ? AND client = ?
    → tokensMatch(tokenHash, token)           // cached BCrypt ✓ (tokenEqualityCache)
  → tokenService.isBatchRequest(...)
    → getByUserIdAndClient(userId, client)   // DB hit on batchTimestampCache miss

The BCrypt result is cached, but the entity fetch is not. For the same user/client within seconds, the DB read is redundant.

Proposal

Add an optional SPI interface following the existing pattern (OgiriAuditHook, OgiriRateLimitHook):

package com.quantipixels.ogiri.security.spi

/**
 * Optional SPI for caching token lookups.
 * When provided, OgiriTokenService checks this cache before hitting the repository.
 * Implementations handle their own invalidation strategy.
 */
interface OgiriTokenLookupCache<T : OgiriToken> {
    fun get(userId: Long, client: String): T?
    fun put(userId: Long, client: String, token: T)
    fun evict(userId: Long, client: String)
    fun evictAll(userId: Long)
}

Wiring

Inject via ObjectProvider<OgiriTokenLookupCache<T>> in OgiriTokenService constructor (same pattern as auditHookProvider). When no bean is present, fall through to repository directly — zero behavior change for existing consumers.

Invalidation points

These already exist in OgiriTokenService and would call evict():

  • createOrUpdateToken()evict(userId, client)
  • deleteToken(userId, client)evict(userId, client)
  • deleteToken(userId, clients)evict each
  • deleteAllForUser(userId)evictAll(userId)
  • cleanupExpiredTokens() / cleanupExpiredTokensBatched() → bulk eviction or clear

Consumer example (Caffeine)

@Component
class CaffeineTokenLookupCache : OgiriTokenLookupCache<UserToken> {
    private val cache: Cache<String, UserToken> = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(5))
        .build()

    override fun get(userId: Long, client: String) = cache.getIfPresent("$userId:$client")
    override fun put(userId: Long, client: String, token: UserToken) = cache.put("$userId:$client", token)
    override fun evict(userId: Long, client: String) = cache.invalidate("$userId:$client")
    override fun evictAll(userId: Long) { /* invalidate matching prefix or use separate index */ }
}

Consumers wanting distributed caching (Redis) for multi-instance deployments would implement the same interface with a Redis-backed store.

Benefits

  • Zero breaking changes — optional via ObjectProvider, existing apps unchanged
  • Follows existing SPI conventions — same pattern as OgiriAuditHook, OgiriRateLimitHook
  • Consumer controls cache strategy — Caffeine, Redis, or custom
  • Small surface area — 4 methods, 1 interface, ~20 lines of wiring in OgiriTokenService
  • Reduces DB load proportional to request rate — high-polling apps benefit most

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions