ReplayCacheBase Class
Everything a replay cache does apart from the one operation only its store can perform. A derived class supplies that operation - reserve this key if it is absent, and say whether it was - and inherits the rest: the freshness window turned into a lifetime and floored, the key namespaced, and the store's answer passed back as the verdict.
public abstract class ReplayCacheBase : Abblix.Jwt.ReplayPrevention.IReplayCacheInheritance System.Object → ReplayCacheBase
Derived
↳ DistributedReplayCache
Implements IReplayCache
Remarks
Every store that can decide a first sighting decides it the same way and they differ only in
spelling - Redis SET key value NX PX ttl, PostgreSQL INSERT ... ON CONFLICT DO NOTHING, DynamoDB a conditional put - so the shape belongs here and the spelling belongs to
a subclass, which costs this assembly no dependency on any of them.
A subclass rather than a delegate because the implementation deserves a NAME. It is what a container registers, what a stack trace prints, and what an operator reads when asking which replay cache a deployment actually wired - and "the strict one" and "the probabilistic one" differ in exactly the way a name is needed to tell apart, since both answer the same contract and only one of them can be relied on to refuse.
What a subclass must NOT do is as fixed as what it must: no read before the write, no release, no retry. Whether the reservation is indivisible is the store's promise, and it is the whole of what distinguishes a strict cache from DistributedReplayCache; a subclass that read first would hand back the very race the shape exists to close.
Redis. One command, and the condition is evaluated by the server inside the write that performs it, so no caller can be between the two. Redis expires the key itself, which is the whole of the retention question here:
public sealed class RedisReplayCache(IConnectionMultiplexer connection, TimeProvider clock, string prefix)
: ReplayCacheBase(clock, prefix)
{
private readonly IDatabase _database = connection.GetDatabase()
protected override Task<bool> ReserveIfAbsentAsync(
string key, TimeSpan timeToLive, CancellationToken cancellationToken)
=> _database.StringSetAsync(key, 1, timeToLive, When.NotExists)
}The stored value is a presence marker and nothing reads it: presence of the key is the whole
fact. When.NotExists is what makes the answer meaningful - with When.Always the
call still compiles, still returns a bool, and returns true every time, so every replay reads
as fresh.
PostgreSQL. The primary key does the deciding, and the statement affects one row when it inserted and none when it conflicted:
INSERT INTO replay_reservations (reservation_key, expires_at)
VALUES (@key, @expiresAt)
ON CONFLICT (reservation_key) DO NOTHINGA row count of one is a first sighting, zero is a replay - the same answer Redis gives, from the
uniqueness constraint rather than from a flag. What differs is retention: a table does not
expire anything, so the subclass computes expires_at from the lifetime it was handed and
something must remove rows past it. Until they are removed the identifier stays reserved, and
the direction of that error is the reassuring one - a replay cache that remembers too long
refuses a request it could have allowed, while one that forgets too early accepts a replay. So
a cleanup that lags is a size problem, never a security one, and it may be a scheduled delete
or a partition drop rather than anything the reservation path waits for.
How to know an implementation is right. Not by reading it: a read-then-write version satisfies the signature, passes every sequential test and fails only under load. Drive it with many callers reserving ONE identifier at once, over separate connections - a single pooled connection can serialize them and hide the defect - and require the count of true answers to be exactly one. That assertion holds under every interleaving when the store decides, and fails as soon as any two callers overlap when it does not.
Methods
ReplayCacheBase.TryReserveAsync(string, DateTimeOffset, CancellationToken) Method
Reserves an identifier, answering whether this is its first sighting.
public System.Threading.Tasks.Task<bool> TryReserveAsync(string identifier, System.DateTimeOffset expiresAt, System.Threading.CancellationToken cancellationToken=default(System.Threading.CancellationToken));Parameters
identifier System.String
What identifies the token. A profile whose identifier is unique only within a scope composes that scope into the value it passes - a SET's "jti" is unique per event feed (RFC 8417 Section 2.2), so its receiver reserves the issuer and the identifier together.
expiresAt System.DateTimeOffset
When the identifier stops being worth remembering, which is the last moment a replay of this token could still pass the caller's own freshness checks. Forgetting earlier would let that token replay; the implementation is free to remember longer.
cancellationToken System.Threading.CancellationToken
Cancels the cache round trip.
Implements TryReserveAsync(string, DateTimeOffset, CancellationToken)
Returns
System.Threading.Tasks.Task<System.Boolean>
True when the identifier was newly reserved and the token is therefore fresh; false when
it was already there, which is a replay.