Skip to content

Move replay protection into Abblix.JWT, one store for every JWT profile - #337

Merged
kirill-abblix merged 1 commit into
developfrom
refactor/replay-cache-to-jwt
Aug 4, 2026
Merged

Move replay protection into Abblix.JWT, one store for every JWT profile#337
kirill-abblix merged 1 commit into
developfrom
refactor/replay-cache-to-jwt

Conversation

@kirill-abblix

Copy link
Copy Markdown
Member

Two implementations of one idea had grown up in two packages: the server reserved jti values for DPoP proofs and client assertions, the security-events receiver reserved them for Security Event Tokens, and neither could reach the other because a receiver must not depend on the OpenID Connect server. The primitive now lives one layer below both, as Abblix.Jwt.ReplayPrevention.IReplayCache with a distributed-cache implementation, so a host running both shares one store instead of keeping two.

What the move had to preserve rather than rebuild

  • The cache key prefix is a plain literal now. It used to be derived from the namespace it lived in, and this is the second relocation of that namespace - each one would have silently changed the key, orphaning the entries of a running deployment and leaving a rolling upgrade without replay protection until they aged out.
  • The server's clock skew and its two log event ids stay in the server, applied by decoration, so an operator's runbook still keys off the numbers it knows.
  • The retention a Security Event Token receiver remembers an identifier for moves onto the validation profile, beside the freshness window it has to outlive. Raising one without the other is now visible in one file instead of split across two.

The 2.3 contract

Features.ReplayPrevention.IJwtReplayCache and its implementation are marked obsolete and keep working: they still resolve, and the implementation now stores through the moved one, so the deprecated and current spellings cannot disagree about whether an identifier has been seen. A host that implemented the contract rather than merely resolving it keeps deciding where replay state lives - the registration detects that case and bridges to it, because a security control that lapses while its registration still looks healthy is the worst way for one to fail.

The three duplicate registrations collapse into one AddReplayPrevention(), which is idempotent and applies the server's policy by decoration, so which package registered the store first cannot decide whether that policy applies.

Coverage

The moved implementation carries its unit tests across (first reservation, repeat, prefix isolation, the lifetime handed to the store, a floored expiry already in the past, an empty identifier). Two new cases in ServiceCollectionOverrideTests pin the parts a reader would otherwise have to trust: a host implementation of the deprecated contract stays in charge, and repeated feature registrations produce exactly one cache. 3722 tests pass across ten suites.

… profile

Two implementations of the same idea had grown up in two packages: the
server reserved jti values for DPoP proofs and client assertions, the
security-events receiver reserved them for Security Event Tokens, and
neither could reach the other because a receiver must not depend on the
OpenID Connect server. The primitive now lives one layer below both, as
Abblix.JWT's IReplayCache and its distributed-cache implementation, so a
host running both shares a single store instead of keeping two.

Three things the move had to preserve rather than rebuild. The cache key
prefix is a plain literal now: it used to be derived from the namespace
it lived in, and this is the second relocation of that namespace, each
of which would have silently orphaned the entries of a running
deployment and left a rolling upgrade with no replay protection until
they aged out. The server's clock skew and its two log event ids stay in
the server, applied by decoration, so an operator's runbook still keys
off the numbers it knows. And the retention that a Security Event Token
receiver remembers an identifier for moves onto the validation profile,
beside the freshness window it has to outlive, where raising one without
the other is visible instead of split across two files.

The contract shipped in 2.3 is marked obsolete and keeps working: it
still resolves, and its implementation now stores through the moved one,
so the deprecated and current spellings cannot disagree about whether an
identifier has been seen. A host that implemented it rather than merely
resolving it keeps deciding where replay state lives - the registration
detects that case and bridges to it, because a security control that
lapses while its registration still looks healthy is the worst way for
one to fail.
@sonarqubecloud

sonarqubecloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

@kirill-abblix
kirill-abblix merged commit a6d65af into develop Aug 4, 2026
22 checks passed
@kirill-abblix
kirill-abblix deleted the refactor/replay-cache-to-jwt branch August 4, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant