diff --git a/src/Abblix.DependencyInjection/README.md b/src/Abblix.DependencyInjection/README.md index 4a75fbfe..27eca95f 100644 --- a/src/Abblix.DependencyInjection/README.md +++ b/src/Abblix.DependencyInjection/README.md @@ -55,9 +55,9 @@ Both operations have keyed counterparts: `ComposeKeyed` and `DecorateKeyed`. `Dependency.Override` constructs a service from the container while substituting only the named dependencies - the clean alternative to hand-built factories that freeze a constructor's shape into calling code: ```csharp -services.AddSingleton(provider => - provider.CreateService( - Dependency.Override(TimeSpan.FromMinutes(10)))); +services.AddSingleton(provider => + provider.CreateService( + Dependency.Override("Abblix.SecurityEvents:ReplayPrevention:"))); ``` Overloads accept a type mapping, an instance, or a factory, and the same overrides ride the `AddSingleton` / `AddScoped` / `AddTransient` overloads this package adds. Every dependency not overridden resolves from the container as usual, so a new constructor parameter on the service does not break the factory. diff --git a/src/Abblix.Jwt/Abblix.Jwt.csproj b/src/Abblix.Jwt/Abblix.Jwt.csproj index ae4960e5..dcdec4f8 100644 --- a/src/Abblix.Jwt/Abblix.Jwt.csproj +++ b/src/Abblix.Jwt/Abblix.Jwt.csproj @@ -35,7 +35,10 @@ + service needs. Options carries IOptions<>, which the ring reads its rollover window from. + Caching.Abstractions carries only IDistributedCache, the store the replay cache reserves identifiers + in; the store itself is the host's to register. --> + diff --git a/src/Abblix.Jwt/README.md b/src/Abblix.Jwt/README.md index aa97156e..3c870d13 100644 --- a/src/Abblix.Jwt/README.md +++ b/src/Abblix.Jwt/README.md @@ -58,6 +58,17 @@ The payload is a `JsonObject` underneath, so claims keep their JSON types - numb The validation pipeline enforces what the specifications say a careless implementation forgets: a key that declares an `alg` is never used for another algorithm when producing or verifying a JWS ([RFC 8725](https://datatracker.ietf.org/doc/html/rfc8725) Section 3.1; JWE key unwrapping selects by `kid` and the header's `alg`, so a decryption key's declared `alg` is not a filter there). An HMAC key shorter than its hash output is rejected (RFC 7518 Section 3.2), and a `crit` header names only parameters a registered handler understands - an unhandled critical parameter rejects the token, on the JWE envelope as on the JWS (RFC 7515 Section 4.1.11). +## Replay protection + +Every JWT profile that forbids replay asks the same question - has this identifier been presented before? - so the primitive lives here rather than in each of them: `IReplayCache` reserves an identifier and answers whether the sighting is the first, in one call, so no caller can read, decide and write in three steps another caller slips between. + +```csharp +services.AddSingleton(provider => + provider.CreateService(Dependency.Override("MyApp:ReplayPrevention:"))); +``` + +The shipped implementation stores in the host's `IDistributedCache`, so a single-instance deployment gets process-local behaviour and a scaled-out one gets shared memory by swapping the store. That store offers Get and Set and no compare-and-set, which makes the answer probabilistic within one cache round trip - enough for the profiles that accept it (RFC 9449 Section 11.1 for DPoP proofs, RFC 8935 Section 2 for redelivered Security Event Tokens), and replaceable behind the same interface by a backend-native primitive where it is not. + ## External keys Signing and decryption do not require the private key to live in the process: the custodian seam delegates the cryptographic operation to an external holder - `AddVaultCustodian` for HashiCorp Vault / OpenBao ([Abblix.JWT.Vault](https://www.nuget.org/packages/Abblix.JWT.Vault)), `AddAzureCustodian` for Azure Key Vault ([Abblix.JWT.Azure](https://www.nuget.org/packages/Abblix.JWT.Azure)), both built on this package's `AddKeyCustodian`. diff --git a/src/Abblix.Jwt/ReplayPrevention/DistributedReplayCache.cs b/src/Abblix.Jwt/ReplayPrevention/DistributedReplayCache.cs new file mode 100644 index 00000000..3c80dfe9 --- /dev/null +++ b/src/Abblix.Jwt/ReplayPrevention/DistributedReplayCache.cs @@ -0,0 +1,74 @@ +// Abblix OIDC Server Library +// Copyright (c) Abblix LLP. All rights reserved. +// +// DISCLAIMER: This software is provided 'as-is', without any express or implied +// warranty. Use at your own risk. Abblix LLP is not liable for any damages +// arising from the use of this software. +// +// LICENSE RESTRICTIONS: This code may not be modified, copied, or redistributed +// in any form outside of the official GitHub repository at: +// https://github.com/Abblix/OIDC.Server. All development and modifications +// must occur within the official repository and are managed solely by Abblix LLP. +// +// Unauthorized use, modification, or distribution of this software is strictly +// prohibited and may be subject to legal action. +// +// For full licensing terms, please visit: +// +// https://oidc.abblix.com/license +// +// CONTACT: For license inquiries or permissions, contact Abblix LLP at +// info@abblix.com + +using Abblix.Utils; +using Microsoft.Extensions.Caching.Distributed; + +namespace Abblix.Jwt.ReplayPrevention; + +/// +/// A replay cache over the host's : process-local when the host +/// registers the in-memory distributed cache, shared when it registers Redis or another backend - +/// so a scaled-out deployment gains one common memory by swapping the store, not the cache. +/// +/// +/// The add-if-absent underneath is probabilistic, not strict: two concurrent presenters of one +/// identifier can both hear "new" within a single cache round trip, because +/// offers Get and Set and no compare-and-set. Each profile +/// decides whether that is acceptable - RFC 9449 Section 11.1 accepts probabilistic replay +/// defence for DPoP proofs, and RFC 8935 Section 2 lets a transmitter redeliver a SET regardless, +/// so a lost race costs one duplicate idempotent pass. A deployment that needs more registers a +/// backend-native implementation behind . +/// +/// The distributed cache the host registered; the store is the host's choice. +/// +/// The clock the retention window is measured against. +/// +/// Keeps these entries out of the way of whatever else shares the host's cache. It is the +/// caller's to choose and its exact text is a deployment contract, not an implementation detail: +/// entries written under one prefix are invisible under another, so changing it mid-rollout +/// leaves the identifiers already reserved unreachable until they age out. +public sealed class DistributedReplayCache( + IDistributedCache cache, + TimeProvider clock, + string keyPrefix) : IReplayCache +{ + private readonly string _keyPrefix = keyPrefix + ?? throw new ArgumentNullException(nameof(keyPrefix)); + + /// + public async Task TryReserveAsync( + string identifier, + DateTimeOffset expiresAt, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(identifier); + + // A time-to-live rather than an absolute moment, because that is what the cache takes. + // The shared primitive floors a value already in the past, so an expiry that has just + // elapsed still records the sighting instead of silently reserving nothing. + return await cache.TryAddAsync( + _keyPrefix + identifier, + expiresAt - clock.GetUtcNow(), + cancellationToken); + } +} diff --git a/src/Abblix.Jwt/ReplayPrevention/IReplayCache.cs b/src/Abblix.Jwt/ReplayPrevention/IReplayCache.cs new file mode 100644 index 00000000..73775b82 --- /dev/null +++ b/src/Abblix.Jwt/ReplayPrevention/IReplayCache.cs @@ -0,0 +1,63 @@ +// Abblix OIDC Server Library +// Copyright (c) Abblix LLP. All rights reserved. +// +// DISCLAIMER: This software is provided 'as-is', without any express or implied +// warranty. Use at your own risk. Abblix LLP is not liable for any damages +// arising from the use of this software. +// +// LICENSE RESTRICTIONS: This code may not be modified, copied, or redistributed +// in any form outside of the official GitHub repository at: +// https://github.com/Abblix/OIDC.Server. All development and modifications +// must occur within the official repository and are managed solely by Abblix LLP. +// +// Unauthorized use, modification, or distribution of this software is strictly +// prohibited and may be subject to legal action. +// +// For full licensing terms, please visit: +// +// https://oidc.abblix.com/license +// +// CONTACT: For license inquiries or permissions, contact Abblix LLP at +// info@abblix.com + +namespace Abblix.Jwt.ReplayPrevention; + +/// +/// Remembers the identifiers of single-use tokens so a second presentation of the same one can +/// be told from the first. Every JWT profile that forbids replay needs this and needs it in the +/// same shape - a DPoP proof (RFC 9449 Section 11.1), a client assertion (RFC 7523 Section 5.2) +/// and a Security Event Token (RFC 8417 Section 2.2) differ in what they call the identifier and +/// how long it stays interesting, never in the question they ask of the cache. +/// +/// +/// The contract is reserve-and-check in one call, so a caller cannot read, decide and write in +/// three steps that another caller slips between. Whether the reservation is strictly atomic is +/// the implementation's promise, not this interface's: the shipped +/// rides IDistributedCache, which exposes only Get +/// and Set, so its answer is probabilistic within one cache round trip. A deployment that needs +/// strict single-use replaces it with a backend-native primitive behind this same interface - +/// Redis SET NX EX, SQL INSERT ... ON CONFLICT DO NOTHING, and their equivalents. +/// +public interface IReplayCache +{ + /// + /// Reserves an identifier, answering whether this is its first sighting. + /// + /// + /// 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. + /// + /// + /// 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. + /// Cancels the cache round trip. + /// + /// True when the identifier was newly reserved and the token is therefore fresh; false when + /// it was already there, which is a replay. + Task TryReserveAsync( + string identifier, + DateTimeOffset expiresAt, + CancellationToken cancellationToken = default); +} diff --git a/src/Abblix.Oidc.Server/Endpoints/ServiceCollectionExtensions.cs b/src/Abblix.Oidc.Server/Endpoints/ServiceCollectionExtensions.cs index 4db3f34e..3ef0c18d 100644 --- a/src/Abblix.Oidc.Server/Endpoints/ServiceCollectionExtensions.cs +++ b/src/Abblix.Oidc.Server/Endpoints/ServiceCollectionExtensions.cs @@ -66,8 +66,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using CompositeRequestFetcher = Abblix.Oidc.Server.Endpoints.Authorization.RequestFetching.CompositeRequestFetcher; -using DistributedJwtReplayCache = Abblix.Oidc.Server.Features.ReplayPrevention.DistributedJwtReplayCache; -using IJwtReplayCache = Abblix.Oidc.Server.Features.ReplayPrevention.IJwtReplayCache; +using Abblix.Oidc.Server.Features.ReplayPrevention; using JwtBearer = Abblix.Oidc.Server.Features.JwtBearer; namespace Abblix.Oidc.Server.Endpoints; @@ -316,12 +315,11 @@ public static IServiceCollection EnablePasswordGrant(this IServiceCollection ser public static IServiceCollection AddJwtBearerGrant(this IServiceCollection services) { services.TryAddSingleton(); - services.TryAddSingleton(); + services.AddReplayPrevention(); - // The replay-cache implementation now lives in Features.ReplayPrevention so DPoP - // and any future consumer can share it. The JwtBearer-namespaced shim is the - // singleton registered concretely; both the canonical interface and the deprecated - // JwtBearer.IJwtReplayCache alias resolve to the same instance for back-compat. + // The storage now lives in Abblix.JWT so a Security Event Token receiver can share it + // without reaching for the OpenID Connect server. Both deprecated spellings still + // resolve, and every one of them reserves identifiers in that same store. #pragma warning disable CS0618 // intentional registration of the deprecated shim services.TryAddSingleton(); #pragma warning restore CS0618 diff --git a/src/Abblix.Oidc.Server/Features/ClientAuthentication/ClientSecretJwtAuthenticator.cs b/src/Abblix.Oidc.Server/Features/ClientAuthentication/ClientSecretJwtAuthenticator.cs index 7a5e5577..77ee0307 100644 --- a/src/Abblix.Oidc.Server/Features/ClientAuthentication/ClientSecretJwtAuthenticator.cs +++ b/src/Abblix.Oidc.Server/Features/ClientAuthentication/ClientSecretJwtAuthenticator.cs @@ -26,7 +26,7 @@ using Abblix.Oidc.Server.Common.Interfaces; using Abblix.Oidc.Server.Features.ClientInformation; using Abblix.Oidc.Server.Features.Licensing; -using Abblix.Oidc.Server.Features.ReplayPrevention; +using Abblix.Jwt.ReplayPrevention; using Abblix.Oidc.Server.Features.Tokens.Validation; using Abblix.Utils; using Microsoft.Extensions.Logging; @@ -50,7 +50,7 @@ public partial class ClientSecretJwtAuthenticator( IClientInfoProvider clientInfoProvider, IRequestInfoProvider requestInfoProvider, TimeProvider clock, - IJwtReplayCache replayCache) : JwtAssertionAuthenticatorBase(logger, replayCache) + IReplayCache replayCache) : JwtAssertionAuthenticatorBase(logger, replayCache) { /// /// Specifies the client authentication method this authenticator supports, which is 'client_secret_jwt'. diff --git a/src/Abblix.Oidc.Server/Features/ClientAuthentication/JwtAssertionAuthenticatorBase.cs b/src/Abblix.Oidc.Server/Features/ClientAuthentication/JwtAssertionAuthenticatorBase.cs index 0706616b..e053b034 100644 --- a/src/Abblix.Oidc.Server/Features/ClientAuthentication/JwtAssertionAuthenticatorBase.cs +++ b/src/Abblix.Oidc.Server/Features/ClientAuthentication/JwtAssertionAuthenticatorBase.cs @@ -23,7 +23,7 @@ using Abblix.Jwt; using Abblix.Oidc.Server.Common.Constants; using Abblix.Oidc.Server.Features.ClientInformation; -using Abblix.Oidc.Server.Features.ReplayPrevention; +using Abblix.Jwt.ReplayPrevention; using Abblix.Oidc.Server.Features.Tokens.Validation; using Abblix.Oidc.Server.Model; using Abblix.Utils; @@ -39,7 +39,7 @@ namespace Abblix.Oidc.Server.Features.ClientAuthentication; /// Replay cache that records assertion jti values and atomically rejects reuse. public abstract partial class JwtAssertionAuthenticatorBase( ILogger logger, - IJwtReplayCache replayCache) : IClientAuthenticator + IReplayCache replayCache) : IClientAuthenticator { /// /// Specifies the client authentication methods supported by this authenticator. @@ -155,7 +155,7 @@ public abstract partial class JwtAssertionAuthenticatorBase( // Single atomic reserve-and-check: record the jti and treat "already present" as a replay. // One call avoids the read-then-write race a separate status check + mark step would leave // between two concurrent presenters of the same assertion. - if (!await replayCache.TryAddAsync(jwtId, expiresAt)) + if (!await replayCache.TryReserveAsync(jwtId, expiresAt)) { LogReplayDetected(jwtId, clientInfo.ClientId); return null; diff --git a/src/Abblix.Oidc.Server/Features/ClientAuthentication/PrivateKeyJwtAuthenticator.cs b/src/Abblix.Oidc.Server/Features/ClientAuthentication/PrivateKeyJwtAuthenticator.cs index 10c577a3..d6fda939 100644 --- a/src/Abblix.Oidc.Server/Features/ClientAuthentication/PrivateKeyJwtAuthenticator.cs +++ b/src/Abblix.Oidc.Server/Features/ClientAuthentication/PrivateKeyJwtAuthenticator.cs @@ -22,7 +22,7 @@ using Abblix.Jwt; using Abblix.Oidc.Server.Common.Constants; -using Abblix.Oidc.Server.Features.ReplayPrevention; +using Abblix.Jwt.ReplayPrevention; using Abblix.Oidc.Server.Features.Tokens.Validation; using Abblix.Utils; using Microsoft.Extensions.DependencyInjection; @@ -39,7 +39,7 @@ namespace Abblix.Oidc.Server.Features.ClientAuthentication; /// Service provider used to resolve scoped dependencies. public class PrivateKeyJwtAuthenticator( ILogger logger, - IJwtReplayCache replayCache, + IReplayCache replayCache, IServiceProvider serviceProvider) : JwtAssertionAuthenticatorBase(logger, replayCache) { /// diff --git a/src/Abblix.Oidc.Server/Features/DPoP/ProofValidator.cs b/src/Abblix.Oidc.Server/Features/DPoP/ProofValidator.cs index 89d7902b..1abddeec 100644 --- a/src/Abblix.Oidc.Server/Features/DPoP/ProofValidator.cs +++ b/src/Abblix.Oidc.Server/Features/DPoP/ProofValidator.cs @@ -27,7 +27,7 @@ using Abblix.Oidc.Server.Common.Configuration; using Abblix.Oidc.Server.Common.Constants; using Abblix.Oidc.Server.Common.Interfaces; -using Abblix.Oidc.Server.Features.ReplayPrevention; +using Abblix.Jwt.ReplayPrevention; using Abblix.Utils; using Microsoft.Extensions.Options; @@ -51,7 +51,7 @@ namespace Abblix.Oidc.Server.Features.DPoP; /// internal sealed class ProofValidator( IJsonWebTokenValidator jwtValidator, - IJwtReplayCache replayCache, + IReplayCache replayCache, IOptionsMonitor options, IRequestInfoProvider requestInfoProvider, TimeProvider timeProvider) : IProofValidator @@ -113,9 +113,10 @@ public async Task> ValidateAsync( // TryAddAsync is single-call by contract — atomic-capable backends close the // read-then-write race natively; the default IDistributedCache fallback retains // the documented probabilistic guarantee accepted under RFC 9449 §11.1. - var fresh = await replayCache.TryAddAsync( + var fresh = await replayCache.TryReserveAsync( jwtId, - issuedAt + options.CurrentValue.DPoP.IssuedAtTolerance); + issuedAt + options.CurrentValue.DPoP.IssuedAtTolerance, + cancellationToken); if (!fresh) { diff --git a/src/Abblix.Oidc.Server/Features/JwtBearer/JwtBearerIssuerProvider.cs b/src/Abblix.Oidc.Server/Features/JwtBearer/JwtBearerIssuerProvider.cs index 6a89652b..43cc315a 100644 --- a/src/Abblix.Oidc.Server/Features/JwtBearer/JwtBearerIssuerProvider.cs +++ b/src/Abblix.Oidc.Server/Features/JwtBearer/JwtBearerIssuerProvider.cs @@ -21,6 +21,7 @@ // info@abblix.com using Abblix.Jwt; +using Abblix.Jwt.ReplayPrevention; using Abblix.Oidc.Server.Common.Configuration; using Abblix.Oidc.Server.Features.SecureHttpFetch; using Microsoft.Extensions.DependencyInjection; @@ -38,11 +39,14 @@ namespace Abblix.Oidc.Server.Features.JwtBearer; /// OIDC configuration options containing JWT Bearer trusted issuers. /// Cache for JWT replay protection per RFC 7523 Section 5.2. /// HTTP fetcher with SSRF protection and caching. +/// Dates the fallback retention window for an assertion without an +/// expiry. public partial class JwtBearerIssuerProvider( ILogger logger, IOptionsMonitor oidcOptions, - ReplayPrevention.IJwtReplayCache replayCache, - [FromKeyedServices(KeySetOwners.Issuer)] ISecureHttpFetcher secureFetcher) : IJwtBearerIssuerProvider + IReplayCache replayCache, + [FromKeyedServices(KeySetOwners.Issuer)] ISecureHttpFetcher secureFetcher, + TimeProvider timeProvider) : IJwtBearerIssuerProvider { /// public JwtBearerOptions Options => oidcOptions.CurrentValue.JwtBearer; @@ -130,5 +134,10 @@ public async IAsyncEnumerable GetSigningKeysAsync(string issuer) /// public async Task IsReplayedAsync(string jti, DateTimeOffset? expiresAt) - => !await replayCache.TryAddAsync(jti, expiresAt); + // An assertion carrying no expiry names no window to remember it for, so the identifier + // is held for the fallback hour - the same guess this provider has always made, now + // stated in one place instead of hidden inside the cache. + => !await replayCache.TryReserveAsync( + jti, + expiresAt ?? timeProvider.GetUtcNow() + ReplayPrevention.ConfiguredReplayCache.DefaultExpiration); } diff --git a/src/Abblix.Oidc.Server/Features/ReplayPrevention/DistributedJwtReplayCache.Logging.cs b/src/Abblix.Oidc.Server/Features/ReplayPrevention/ConfiguredReplayCache.Logging.cs similarity index 63% rename from src/Abblix.Oidc.Server/Features/ReplayPrevention/DistributedJwtReplayCache.Logging.cs rename to src/Abblix.Oidc.Server/Features/ReplayPrevention/ConfiguredReplayCache.Logging.cs index fdb2cdb3..c061af28 100644 --- a/src/Abblix.Oidc.Server/Features/ReplayPrevention/DistributedJwtReplayCache.Logging.cs +++ b/src/Abblix.Oidc.Server/Features/ReplayPrevention/ConfiguredReplayCache.Logging.cs @@ -24,17 +24,17 @@ namespace Abblix.Oidc.Server.Features.ReplayPrevention; -partial class DistributedJwtReplayCache +partial class ConfiguredReplayCache { - [LoggerMessage( - EventId = LogEvents.Tokens.DistributedJwtReplayCache.ReplayDetected, - Level = LogLevel.Debug, - Message = "JWT replay detected for jti {JwtId}")] - private partial void LogReplayDetected(string JwtId); + [LoggerMessage( + EventId = LogEvents.Tokens.DistributedJwtReplayCache.ReplayDetected, + Level = LogLevel.Debug, + Message = "JWT replay detected for jti {JwtId}")] + private partial void LogReplayDetected(string JwtId); - [LoggerMessage( - EventId = LogEvents.Tokens.DistributedJwtReplayCache.MarkedAsUsed, - Level = LogLevel.Debug, - Message = "Marked jti {JwtId} as used, expires in {Expiration}")] - private partial void LogMarkedAsUsed(string JwtId, TimeSpan Expiration); + [LoggerMessage( + EventId = LogEvents.Tokens.DistributedJwtReplayCache.MarkedAsUsed, + Level = LogLevel.Debug, + Message = "Marked jti {JwtId} as used, remembered until {ExpiresAt}")] + private partial void LogMarkedAsUsed(string JwtId, DateTimeOffset ExpiresAt); } diff --git a/src/Abblix.Oidc.Server/Features/ReplayPrevention/ConfiguredReplayCache.cs b/src/Abblix.Oidc.Server/Features/ReplayPrevention/ConfiguredReplayCache.cs new file mode 100644 index 00000000..b1580750 --- /dev/null +++ b/src/Abblix.Oidc.Server/Features/ReplayPrevention/ConfiguredReplayCache.cs @@ -0,0 +1,74 @@ +// Abblix OIDC Server Library +// Copyright (c) Abblix LLP. All rights reserved. +// +// DISCLAIMER: This software is provided 'as-is', without any express or implied +// warranty. Use at your own risk. Abblix LLP is not liable for any damages +// arising from the use of this software. +// +// LICENSE RESTRICTIONS: This code may not be modified, copied, or redistributed +// in any form outside of the official GitHub repository at: +// https://github.com/Abblix/OIDC.Server. All development and modifications +// must occur within the official repository and are managed solely by Abblix LLP. +// +// Unauthorized use, modification, or distribution of this software is strictly +// prohibited and may be subject to legal action. +// +// For full licensing terms, please visit: +// +// https://oidc.abblix.com/license +// +// CONTACT: For license inquiries or permissions, contact Abblix LLP at +// info@abblix.com + +using Abblix.Jwt.ReplayPrevention; +using Abblix.Oidc.Server.Common.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Abblix.Oidc.Server.Features.ReplayPrevention; + +/// +/// The server's replay cache: the storage primitive from Abblix.JWT wearing this deployment's +/// policy - the configured clock skew on top of every retention window, and the two log events +/// an operator's runbook keys off. +/// +/// +/// The skew is read from and applied to every consumer, +/// DPoP proofs included. That is deliberate rather than tidy: one knob decides how far this +/// server's notion of "expired" may lag a presenter's, and splitting it per profile would let a +/// deployment tighten one path while believing it had tightened all of them. +/// +/// Records the two replay events. +/// The storage the reservation actually lands in. +/// Where the clock skew is read from, re-read per call so a live +/// configuration change takes effect without a restart. +internal sealed partial class ConfiguredReplayCache( + ILogger logger, + IReplayCache inner, + IOptionsMonitor options) : IReplayCache +{ + /// + /// How long an identifier is remembered when its token names no expiry. RFC 7523 Section 3 + /// makes "exp" REQUIRED in an assertion, so this is the fallback for a token that arrived + /// without one rather than a window anything is designed around. + /// + internal static readonly TimeSpan DefaultExpiration = TimeSpan.FromHours(1); + + /// + public async Task TryReserveAsync( + string identifier, + DateTimeOffset expiresAt, + CancellationToken cancellationToken = default) + { + var skewed = expiresAt + options.CurrentValue.JwtBearer.ClockSkew; + + if (!await inner.TryReserveAsync(identifier, skewed, cancellationToken)) + { + LogReplayDetected(identifier); + return false; + } + + LogMarkedAsUsed(identifier, skewed); + return true; + } +} diff --git a/src/Abblix.Oidc.Server/Features/ReplayPrevention/DistributedJwtReplayCache.cs b/src/Abblix.Oidc.Server/Features/ReplayPrevention/DistributedJwtReplayCache.cs index 980b90b9..f93b76bb 100644 --- a/src/Abblix.Oidc.Server/Features/ReplayPrevention/DistributedJwtReplayCache.cs +++ b/src/Abblix.Oidc.Server/Features/ReplayPrevention/DistributedJwtReplayCache.cs @@ -20,77 +20,38 @@ // CONTACT: For license inquiries or permissions, contact Abblix LLP at // info@abblix.com -using Abblix.Oidc.Server.Common.Configuration; -using Abblix.Utils; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; +using System.Diagnostics.CodeAnalysis; +using Abblix.Jwt.ReplayPrevention; namespace Abblix.Oidc.Server.Features.ReplayPrevention; /// -/// Distributed cache implementation of for JWT replay protection. -/// Uses to store JTIs, enabling multi-instance deployments. +/// The deprecated contract's default implementation, kept so a host that resolves +/// still receives a working object. It stores nothing of its own: +/// every reservation goes to the same the server's own consumers use, +/// so the deprecated and current spellings share one set of entries and cannot disagree about +/// whether an identifier has been seen. /// /// -/// This implementation stores JTIs with automatic expiration matching the JWT's lifetime. -/// Works with Redis, SQL Server, NCache, or any IDistributedCache implementation. -/// Clock skew buffer is configurable via . +/// The only behaviour left here is the one the current contract deliberately dropped: an absent +/// expiry. The moved contract requires its caller to say when an identifier stops being worth +/// remembering, because a cache that guesses either outlives or forgets the window its caller +/// actually validates against. This shim keeps guessing on its callers' behalf, with the hour the +/// deprecated contract has always used. /// -/// Logger for recording replay detection events. -/// The distributed cache for storing JTIs. -/// JWT Bearer options for configurable settings like clock skew. -/// Provides access to the current time. -public partial class DistributedJwtReplayCache( - ILogger logger, - IDistributedCache cache, - IOptionsMonitor options, - TimeProvider timeProvider) : IJwtReplayCache +/// Where the reservation lands. +/// Turns an absent expiry into an absolute moment. +[Obsolete($"Use {nameof(Abblix)}.{nameof(Jwt)}.{nameof(Jwt.ReplayPrevention)}." + + $"{nameof(IReplayCache)}, registered by the same calls that used to register this type.")] +[SuppressMessage("Major Code Smell", "S1133:Deprecated code should be removed", + Justification = "Backward-compat shim for hosts that resolve the deprecated contract; removal is a major-version concern.")] +public class DistributedJwtReplayCache( + IReplayCache replayCache, + TimeProvider timeProvider) : IJwtReplayCache { - /// - /// Cache key prefix for JTI entries to avoid collisions with other cache data. - /// Stable literal - preserved across the namespace move so existing Redis entries - /// from prior deployments stay valid through the rolling upgrade window. - /// - private const string CacheKeyPrefix = - $"{nameof(Abblix)}.{nameof(Oidc)}.{nameof(Server)}.{nameof(Features)}.{nameof(ReplayPrevention)}:"; - - /// - /// Default expiration time for JTIs when the JWT doesn't specify an expiration. - /// - private static readonly TimeSpan DefaultExpiration = TimeSpan.FromHours(1); - - /// - /// - /// exposes only Get + Set, no atomic compare-and-set - /// primitive. Two concurrent presenters of the same jti can both observe a cache - /// miss before either writes, so the duplicate-detection guarantee is probabilistic - /// rather than strict; the race window is bounded by the cache round-trip. RFC 9449 - /// §11.1 accepts probabilistic replay defence for DPoP proofs. Hosts that need - /// strict atomicity should plug in a backend-aware implementation (Redis - /// SET ... NX EX via StackExchange.Redis, SQL INSERT ... ON CONFLICT - /// DO NOTHING, etc.). - /// - public async Task TryAddAsync(string jti, DateTimeOffset? expiresAt) - { - var cacheKey = CacheKeyPrefix + jti; - - var now = timeProvider.GetUtcNow(); - var clockSkew = options.CurrentValue.JwtBearer.ClockSkew; - - // TTL = JWT expiration + clock-skew buffer, or a sane default. The shared primitive - // floors a zero/negative result so an expiry-already-past still records the sighting. - var expiration = expiresAt.HasValue - ? expiresAt.Value - now + clockSkew - : DefaultExpiration; - - if (!await cache.TryAddAsync(cacheKey, expiration)) - { - LogReplayDetected(jti); - return false; - } - - LogMarkedAsUsed(jti, expiration); - return true; - } + /// + public Task TryAddAsync(string jti, DateTimeOffset? expiresAt) + => replayCache.TryReserveAsync( + jti, + expiresAt ?? timeProvider.GetUtcNow() + ConfiguredReplayCache.DefaultExpiration); } diff --git a/src/Abblix.Oidc.Server/Features/ReplayPrevention/IJwtReplayCache.cs b/src/Abblix.Oidc.Server/Features/ReplayPrevention/IJwtReplayCache.cs index 542aac6a..44ba2c6b 100644 --- a/src/Abblix.Oidc.Server/Features/ReplayPrevention/IJwtReplayCache.cs +++ b/src/Abblix.Oidc.Server/Features/ReplayPrevention/IJwtReplayCache.cs @@ -20,6 +20,9 @@ // CONTACT: For license inquiries or permissions, contact Abblix LLP at // info@abblix.com +using System.Diagnostics.CodeAnalysis; +using Abblix.Jwt.ReplayPrevention; + namespace Abblix.Oidc.Server.Features.ReplayPrevention; /// @@ -29,9 +32,18 @@ namespace Abblix.Oidc.Server.Features.ReplayPrevention; /// so a single distributed-cache instance serves every consumer. /// /// -/// Implementations should use distributed storage (e.g., Redis) so multi-instance -/// deployments share the replay-protection state. +/// The primitive now lives in Abblix.JWT as , one layer below this +/// package, because Security Event Token receivers need the same reserve-and-check and cannot +/// reference the OpenID Connect server to get it. This contract remains registered and working +/// for host code that names it, and its default implementation stores through the moved one, so +/// both spellings share a single set of entries. /// +[Obsolete($"Use {nameof(Abblix)}.{nameof(Jwt)}.{nameof(Jwt.ReplayPrevention)}." + + $"{nameof(IReplayCache)}.{nameof(IReplayCache.TryReserveAsync)}, which takes the " + + "moment the identifier stops being worth remembering rather than a nullable expiry, " + + "and accepts a cancellation token.")] +[SuppressMessage("Major Code Smell", "S1133:Deprecated code should be removed", + Justification = "Backward-compat contract for hosts that implemented it; removal is a major-version concern.")] public interface IJwtReplayCache { /// @@ -42,8 +54,8 @@ public interface IJwtReplayCache /// /// /// - /// Atomic-capable backends close the race natively: Redis SET … NX EX - /// (via StackExchange.Redis), SQL INSERT … ON CONFLICT DO NOTHING, + /// Atomic-capable backends close the race natively: Redis SET ... NX EX + /// (via StackExchange.Redis), SQL INSERT ... ON CONFLICT DO NOTHING, /// Memcached add, in-memory ConcurrentDictionary.TryAdd. /// /// @@ -54,7 +66,7 @@ public interface IJwtReplayCache /// provides only a probabilistic guarantee: two concurrent presenters of the /// same jti can both observe a miss before either writes. The race window is /// bounded by the cache round-trip and RFC 9449 §11.1 accepts probabilistic - /// replay defence — but hosts that need strict atomicity should override the + /// replay defence - but hosts that need strict atomicity should override the /// registration with a backend-aware implementation that bypasses /// and /// talks to the chosen backend's atomic primitive directly. diff --git a/src/Abblix.Oidc.Server/Features/ReplayPrevention/LegacyReplayCacheBridge.cs b/src/Abblix.Oidc.Server/Features/ReplayPrevention/LegacyReplayCacheBridge.cs new file mode 100644 index 00000000..22457d50 --- /dev/null +++ b/src/Abblix.Oidc.Server/Features/ReplayPrevention/LegacyReplayCacheBridge.cs @@ -0,0 +1,53 @@ +// Abblix OIDC Server Library +// Copyright (c) Abblix LLP. All rights reserved. +// +// DISCLAIMER: This software is provided 'as-is', without any express or implied +// warranty. Use at your own risk. Abblix LLP is not liable for any damages +// arising from the use of this software. +// +// LICENSE RESTRICTIONS: This code may not be modified, copied, or redistributed +// in any form outside of the official GitHub repository at: +// https://github.com/Abblix/OIDC.Server. All development and modifications +// must occur within the official repository and are managed solely by Abblix LLP. +// +// Unauthorized use, modification, or distribution of this software is strictly +// prohibited and may be subject to legal action. +// +// For full licensing terms, please visit: +// +// https://oidc.abblix.com/license +// +// CONTACT: For license inquiries or permissions, contact Abblix LLP at +// info@abblix.com + +using System.Diagnostics.CodeAnalysis; +using Abblix.Jwt.ReplayPrevention; + +namespace Abblix.Oidc.Server.Features.ReplayPrevention; + +/// +/// Routes the server's reservations into a host's own implementation of the deprecated contract. +/// +/// +/// A host that replaced did so to decide where replay state lives - +/// a strictly atomic backend, most often. Once the server's own consumers moved to +/// , that decision would have stopped taking effect while the +/// registration still looked healthy, which is the worst way for a security control to lapse. +/// This bridge is registered in exactly that case, so the host stays in charge until it migrates. +/// +/// The host's implementation of the deprecated contract. +[SuppressMessage("Major Code Smell", "S1133:Deprecated code should be removed", + Justification = "Bridges a deprecated contract a host may still implement; removal is a major-version concern.")] +internal sealed class LegacyReplayCacheBridge( +#pragma warning disable CS0618 // the deprecated contract is this type's whole reason to exist + IJwtReplayCache legacy) +#pragma warning restore CS0618 + : IReplayCache +{ + /// + public Task TryReserveAsync( + string identifier, + DateTimeOffset expiresAt, + CancellationToken cancellationToken = default) + => legacy.TryAddAsync(identifier, expiresAt); +} diff --git a/src/Abblix.Oidc.Server/Features/ReplayPrevention/ServiceCollectionExtensions.cs b/src/Abblix.Oidc.Server/Features/ReplayPrevention/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..fdd2fd49 --- /dev/null +++ b/src/Abblix.Oidc.Server/Features/ReplayPrevention/ServiceCollectionExtensions.cs @@ -0,0 +1,96 @@ +// Abblix OIDC Server Library +// Copyright (c) Abblix LLP. All rights reserved. +// +// DISCLAIMER: This software is provided 'as-is', without any express or implied +// warranty. Use at your own risk. Abblix LLP is not liable for any damages +// arising from the use of this software. +// +// LICENSE RESTRICTIONS: This code may not be modified, copied, or redistributed +// in any form outside of the official GitHub repository at: +// https://github.com/Abblix/OIDC.Server. All development and modifications +// must occur within the official repository and are managed solely by Abblix LLP. +// +// Unauthorized use, modification, or distribution of this software is strictly +// prohibited and may be subject to legal action. +// +// For full licensing terms, please visit: +// +// https://oidc.abblix.com/license +// +// CONTACT: For license inquiries or permissions, contact Abblix LLP at +// info@abblix.com + +using Abblix.DependencyInjection; +using Abblix.Jwt.ReplayPrevention; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Abblix.Oidc.Server.Features.ReplayPrevention; + +/// +/// Wires replay protection, which several unrelated features need and none of them owns: +/// JWT-bearer assertions (RFC 7523 Section 5.2), client assertions, and DPoP proofs +/// (RFC 9449 Section 11.1) all reserve identifiers in the same place. +/// +public static class ServiceCollectionExtensions +{ + /// + /// The prefix every entry this server writes carries. A stable literal on purpose: it was + /// derived from a namespace that has since moved twice, and each move would have silently + /// orphaned the entries of a running deployment, leaving a rolling upgrade with no replay + /// protection for the length of its retention window. It is text now so that cannot happen + /// again. + /// + private const string CacheKeyPrefix = "Abblix.Oidc.Server.Features.ReplayPrevention:"; + + /// + /// Registers the replay cache and the deprecated contract that still resolves to it. + /// + /// + /// Idempotent and TryAdd throughout, because three unrelated feature registrations call it + /// and a host may have decided any part of it beforehand. + /// + /// The service collection. + public static IServiceCollection AddReplayPrevention(this IServiceCollection services) + { + services.TryAddSingleton(TimeProvider.System); + +#pragma warning disable CS0618 // the deprecated contract is what this whole method is about + var deprecated = services.FirstOrDefault( + descriptor => descriptor.ServiceType == typeof(IJwtReplayCache)); + + // Three feature registrations call this, and the decoration below is not idempotent by + // itself - applying it twice would skew the window twice and log every reservation twice. + // The shim registered at the end is the record that this already ran, and its + // implementation type is what tells it apart from a host's own. + if (deprecated is { ImplementationType: var implementation } + && implementation == typeof(DistributedJwtReplayCache)) + { + return services; + } + + // A host that brought its own implementation of the deprecated contract keeps deciding + // where replay state lives: bridging to it is what stops the move from quietly sidelining + // an override that still looks registered. + if (deprecated is not null) + { + services.TryAddSingleton(); + } +#pragma warning restore CS0618 + + services.TryAddSingleton(provider => + provider.CreateService(Dependency.Override(CacheKeyPrefix))); + + // Decorated rather than composed in one factory, so this server's policy also reaches a + // store some other package registered first - a host running both this server and a + // Security Event Token receiver shares one replay store, and which of them registered it + // must not decide whether the server's clock skew and log events apply. + services.Decorate(); + +#pragma warning disable CS0618 // deliberate registration of the deprecated shim + services.TryAddSingleton(); +#pragma warning restore CS0618 + + return services; + } +} diff --git a/src/Abblix.Oidc.Server/Features/ServiceCollectionExtensions.cs b/src/Abblix.Oidc.Server/Features/ServiceCollectionExtensions.cs index 7745bec8..fd09a68a 100644 --- a/src/Abblix.Oidc.Server/Features/ServiceCollectionExtensions.cs +++ b/src/Abblix.Oidc.Server/Features/ServiceCollectionExtensions.cs @@ -34,6 +34,7 @@ using Abblix.Oidc.Server.Features.BackChannelAuthentication.GrantProcessors; using Abblix.Oidc.Server.Features.BackChannelAuthentication.Interfaces; using Abblix.Oidc.Server.Features.ClientAuthentication; +using Abblix.Oidc.Server.Features.ReplayPrevention; using Abblix.Oidc.Server.Features.ClientInformation; using Abblix.Oidc.Server.Features.Consents; using Abblix.Oidc.Server.Features.DeviceAuthorization; @@ -108,11 +109,9 @@ public static IServiceCollection AddClientAuthentication(this IServiceCollection ]); // JWT assertion authenticators (client_secret_jwt / private_key_jwt) record assertion jti - // values in the replay cache; defensive TryAdd so deployments that never call AddDPoP or - // enable JWT Bearer still resolve the dependency. - services.TryAddSingleton< - ReplayPrevention.IJwtReplayCache, - ReplayPrevention.DistributedJwtReplayCache>(); + // values in the replay cache; called defensively so deployments that never call AddDPoP + // or enable JWT Bearer still resolve the dependency. + services.AddReplayPrevention(); return services.Compose(); } @@ -866,9 +865,7 @@ public static IServiceCollection AddNonces(this IServiceCollection services) public static IServiceCollection AddDPoP(this IServiceCollection services) { services.TryAddSingleton(); - services.TryAddSingleton< - ReplayPrevention.IJwtReplayCache, - ReplayPrevention.DistributedJwtReplayCache>(); + services.AddReplayPrevention(); return services.AddNonces(); } } diff --git a/src/Abblix.SecurityEvents/Abstractions/IJtiReplayCache.cs b/src/Abblix.SecurityEvents/Abstractions/IJtiReplayCache.cs deleted file mode 100644 index 6a0dbbf9..00000000 --- a/src/Abblix.SecurityEvents/Abstractions/IJtiReplayCache.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Abblix OIDC Server Library -// Copyright (c) Abblix LLP. All rights reserved. -// -// DISCLAIMER: This software is provided 'as-is', without any express or implied -// warranty. Use at your own risk. Abblix LLP is not liable for any damages -// arising from the use of this software. -// -// LICENSE RESTRICTIONS: This code may not be modified, copied, or redistributed -// in any form outside of the official GitHub repository at: -// https://github.com/Abblix/OIDC.Server. All development and modifications -// must occur within the official repository and are managed solely by Abblix LLP. -// -// Unauthorized use, modification, or distribution of this software is strictly -// prohibited and may be subject to legal action. -// -// For full licensing terms, please visit: -// -// https://oidc.abblix.com/license -// -// CONTACT: For license inquiries or permissions, contact Abblix LLP at -// info@abblix.com - -namespace Abblix.SecurityEvents.Abstractions; - -/// -/// Tracks which SETs have already been processed, by the use RFC 8417 Section 2.2 names for the -/// "jti" claim: "MAY be used by clients to track whether a particular SET has already been -/// received". -/// -/// -/// -/// Registration is deliberately OUTSIDE the validation pipeline. Validation answers a question -/// and is free of side effects; registering an identifier is a mutation, and a pipeline that -/// mutated on a token a later step rejects would need an undo. The consumer calls this after a -/// successful validation and before acting on the event. -/// -/// -/// A replay is not a protocol error: RFC 8935 Section 2 lets a transmitter deliver the same SET -/// again regardless of earlier responses, so event processing is idempotent by contract, and a -/// repeat is skipped and acknowledged rather than reported. -/// -/// -public interface IJtiReplayCache -{ - /// - /// Registers a token identifier, telling a first delivery from a repeat. - /// - /// - /// The token's issuer. The pair is the key, because "jti" is unique "within a particular - /// event feed" (RFC 8417 Section 2.2) - two issuers may mint the same identifier and neither - /// is replaying the other. - /// The token's "jti" value. - /// - /// The token's "iat", which is what bounds the cache's memory: the validation window rejects - /// anything older, so entries beyond the window are safe to evict. - /// Cancels I/O a distributed implementation performs. - /// True when the identifier is new and now registered; false when it was seen before. - /// - Task TryRegisterAsync( - string issuer, - string jwtId, - DateTimeOffset issuedAt, - CancellationToken cancellationToken = default); -} diff --git a/src/Abblix.SecurityEvents/Infrastructure/DistributedJtiReplayCache.cs b/src/Abblix.SecurityEvents/Infrastructure/DistributedJtiReplayCache.cs deleted file mode 100644 index e928f2ac..00000000 --- a/src/Abblix.SecurityEvents/Infrastructure/DistributedJtiReplayCache.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Abblix OIDC Server Library -// Copyright (c) Abblix LLP. All rights reserved. -// -// DISCLAIMER: This software is provided 'as-is', without any express or implied -// warranty. Use at your own risk. Abblix LLP is not liable for any damages -// arising from the use of this software. -// -// LICENSE RESTRICTIONS: This code may not be modified, copied, or redistributed -// in any form outside of the official GitHub repository at: -// https://github.com/Abblix/OIDC.Server. All development and modifications -// must occur within the official repository and are managed solely by Abblix LLP. -// -// Unauthorized use, modification, or distribution of this software is strictly -// prohibited and may be subject to legal action. -// -// For full licensing terms, please visit: -// -// https://oidc.abblix.com/license -// -// CONTACT: For license inquiries or permissions, contact Abblix LLP at -// info@abblix.com - -using Abblix.SecurityEvents.Abstractions; -using Abblix.Utils; -using Microsoft.Extensions.Caching.Distributed; - -namespace Abblix.SecurityEvents.Infrastructure; - -/// -/// A replay cache over the host's : process-local when the host -/// registers the in-memory distributed cache, shared when it registers Redis or another backend - -/// so a scaled-out receiver gets one feed-wide memory by swapping the store, not the cache. -/// -/// -/// -/// Eviction leans on the validation window: a token whose "iat" is older than the receiver's -/// tolerance never reaches the cache, because the freshness step rejected it first. An entry is -/// therefore stored until its token's issue time plus the retention, after which the identifier -/// is unreachable and safe to forget - which is why the retention must be at least the validation -/// tolerance, and the constructor refuses a zero or negative one outright. -/// -/// -/// The underlying add-if-absent is probabilistic, not strict: two concurrent deliveries of the -/// same SET can both hear "new" within one cache round-trip. That is acceptable here by contract - -/// RFC 8935 Section 2 lets a transmitter deliver the same SET again regardless of earlier -/// responses, so a receiver processes events idempotently and a lost race costs a duplicate -/// idempotent pass, never a security failure. A host needing strict exactly-once plugs a -/// backend-aware implementation behind the same interface. -/// -/// -/// The distributed cache the host registered; the store is the host's choice. -/// -/// The receiver's clock. -/// -/// How long an identifier is remembered past its token's issue time. Must cover the validation -/// profile's issued-at tolerance with a margin - an entry evicted while its token still passes -/// freshness would let that token replay. -public sealed class DistributedJtiReplayCache( - IDistributedCache cache, - TimeProvider clock, - TimeSpan retention) : IJtiReplayCache -{ - /// - /// Keeps the entries out of the way of whatever else shares the host's cache. Derived from - /// the type's own name, so it follows a rename; entries orphaned by such a rename age out - /// within the retention and cost at most one duplicate idempotent pass. - /// - private const string CacheKeyPrefix = - $"{nameof(Abblix)}.{nameof(SecurityEvents)}:{nameof(DistributedJtiReplayCache)}:"; - - private readonly TimeSpan _retention = retention <= TimeSpan.Zero - ? throw new ArgumentOutOfRangeException(nameof(retention), retention, "A replay cache remembering nothing detects nothing.") - : retention; - - /// - public async Task TryRegisterAsync( - string issuer, - string jwtId, - DateTimeOffset issuedAt, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrEmpty(issuer); - ArgumentException.ThrowIfNullOrEmpty(jwtId); - - // Escaping removes ':' from both parts, so the separator is unambiguous and distinct - // (issuer, jti) pairs cannot collide onto one key - the pair is the key because "jti" is - // unique only "within a particular event feed" (RFC 8417 Section 2.2). - var cacheKey = $"{CacheKeyPrefix}{Uri.EscapeDataString(issuer)}:{Uri.EscapeDataString(jwtId)}"; - - return await cache.TryAddAsync(cacheKey, issuedAt + _retention - clock.GetUtcNow(), cancellationToken); - } -} diff --git a/src/Abblix.SecurityEvents/Infrastructure/ServiceCollectionExtensions.cs b/src/Abblix.SecurityEvents/Infrastructure/ServiceCollectionExtensions.cs index e3834d0f..113efa91 100644 --- a/src/Abblix.SecurityEvents/Infrastructure/ServiceCollectionExtensions.cs +++ b/src/Abblix.SecurityEvents/Infrastructure/ServiceCollectionExtensions.cs @@ -22,6 +22,7 @@ using Abblix.DependencyInjection; using Abblix.Jwt; +using Abblix.Jwt.ReplayPrevention; using Abblix.SecurityEvents.Abstractions; using Abblix.SecurityEvents.Events; using Abblix.SecurityEvents.Validation; @@ -182,29 +183,39 @@ public static IServiceCollection AddJwksKeyResolution( /// /// Registers the replay cache over the host's IDistributedCache as the - /// . + /// . /// /// + /// /// The store itself is the host's choice and is deliberately not registered here: /// AddDistributedMemoryCache() gives a single-instance receiver process-local /// behavior, Redis or another backend gives a scaled-out one a shared memory - the same /// registration either way. + /// + /// + /// How long an identifier is remembered comes from the validation profile rather than from + /// here, because the retention only makes sense against the freshness window it has to + /// outlive - see . The + /// contract itself lives in Abblix.JWT, so a host that also runs the OpenID Connect server + /// shares one replay store between its DPoP proofs, its client assertions and its events. + /// /// /// The service collection. - /// - /// How long identifiers are remembered past their tokens' issue time; must cover the - /// validation profile's issued-at tolerance with a margin. The default doubles the default - /// tolerance of . - public static IServiceCollection AddDistributedReplayCache( - this IServiceCollection services, - TimeSpan? retention = null) + public static IServiceCollection AddDistributedReplayCache(this IServiceCollection services) { services.TryAddSingleton(TimeProvider.System); - services.TryAddSingleton( - provider => provider.CreateService( - Dependency.Override(retention ?? TimeSpan.FromMinutes(10)))); + services.TryAddSingleton( + provider => provider.CreateService( + Dependency.Override(CacheKeyPrefix))); return services; } + /// + /// Keeps these entries out of the way of whatever else shares the host's cache. A stable + /// literal: entries written under one prefix are unreachable under another, so a rolling + /// upgrade that changed it would run without replay protection until they aged out. + /// + private const string CacheKeyPrefix = "Abblix.SecurityEvents:ReplayPrevention:"; + } diff --git a/src/Abblix.SecurityEvents/README.md b/src/Abblix.SecurityEvents/README.md index 8fabd037..2b491cb3 100644 --- a/src/Abblix.SecurityEvents/README.md +++ b/src/Abblix.SecurityEvents/README.md @@ -92,7 +92,8 @@ services.AddSecurityEvents(options => }); services.AddJwksKeyResolution(); // receivers: issuers' keys from their published JWK Sets services.AddDistributedMemoryCache(); // or Redis: the replay cache rides the host's IDistributedCache -services.AddDistributedReplayCache(); // receivers: "jti" replay protection over that store +services.AddDistributedReplayCache(); // receivers: "jti" replay protection over that store, + // held for SecurityEventTokenValidationOptions.ReplayRetention ``` A pure receiver registers a key resolver and never configures signing; a pure transmitter does diff --git a/src/Abblix.SecurityEvents/Validation/SecurityEventTokenValidationOptions.cs b/src/Abblix.SecurityEvents/Validation/SecurityEventTokenValidationOptions.cs index a7e69236..927e5412 100644 --- a/src/Abblix.SecurityEvents/Validation/SecurityEventTokenValidationOptions.cs +++ b/src/Abblix.SecurityEvents/Validation/SecurityEventTokenValidationOptions.cs @@ -51,4 +51,13 @@ public record SecurityEventTokenValidationOptions /// instead of remembering every identifier forever, because anything older fails here first. /// public TimeSpan IssuedAtTolerance { get; init; } = TimeSpan.FromMinutes(5); + + /// + /// How long past a token's issue time its identifier stays in the replay cache. It must + /// cover with a margin, because an identifier forgotten + /// while its token still passes the freshness window above is an identifier that token can + /// replay on. The default doubles the default tolerance, and raising one without the other + /// is the mistake this pairing is written side by side to prevent. + /// + public TimeSpan ReplayRetention { get; init; } = TimeSpan.FromMinutes(10); } diff --git a/src/Abblix.SharedSignals/Receiver/PushDeliveryHandler.cs b/src/Abblix.SharedSignals/Receiver/PushDeliveryHandler.cs index 0b984f68..cd839d74 100644 --- a/src/Abblix.SharedSignals/Receiver/PushDeliveryHandler.cs +++ b/src/Abblix.SharedSignals/Receiver/PushDeliveryHandler.cs @@ -21,7 +21,7 @@ // info@abblix.com using System.Net.Http.Headers; -using Abblix.SecurityEvents.Abstractions; +using Abblix.Jwt.ReplayPrevention; using Abblix.SecurityEvents.Delivery; using Abblix.SecurityEvents.Validation; @@ -51,7 +51,7 @@ public sealed class PushDeliveryHandler( ISecurityEventTokenValidator validator, SsfValidationOptions options, ISecurityEventSink sink, - IJtiReplayCache? replayCache = null) + IReplayCache? replayCache = null) { /// /// Handles one push transmission. @@ -110,7 +110,16 @@ public async Task HandleAsync( + "'iat' are REQUIRED (RFC 8417 Section 2.2).")); } - if (!await replayCache.TryRegisterAsync(issuer, jwtId, issuedAt, cancellationToken)) + // The issuer belongs in the key because "jti" is unique only "within a particular + // event feed" (RFC 8417 Section 2.2), and escaping removes ':' from both halves so + // two distinct pairs cannot compose onto one identifier. + var identifier = + $"{Uri.EscapeDataString(issuer)}:{Uri.EscapeDataString(jwtId)}"; + + if (!await replayCache.TryReserveAsync( + identifier, + issuedAt + options.ReplayRetention, + cancellationToken)) { // A redelivery of something already processed: acknowledged, never re-consumed. return PushDeliveryResult.Accepted; diff --git a/tests/Abblix.Jwt.UnitTests/Abblix.Jwt.UnitTests.csproj b/tests/Abblix.Jwt.UnitTests/Abblix.Jwt.UnitTests.csproj index 6d02e7c1..682785f8 100644 --- a/tests/Abblix.Jwt.UnitTests/Abblix.Jwt.UnitTests.csproj +++ b/tests/Abblix.Jwt.UnitTests/Abblix.Jwt.UnitTests.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/Abblix.Jwt.UnitTests/ReplayPrevention/DistributedReplayCacheTests.cs b/tests/Abblix.Jwt.UnitTests/ReplayPrevention/DistributedReplayCacheTests.cs new file mode 100644 index 00000000..5ce8a80e --- /dev/null +++ b/tests/Abblix.Jwt.UnitTests/ReplayPrevention/DistributedReplayCacheTests.cs @@ -0,0 +1,164 @@ +// Abblix OIDC Server Library +// Copyright (c) Abblix LLP. All rights reserved. +// +// DISCLAIMER: This software is provided 'as-is', without any express or implied +// warranty. Use at your own risk. Abblix LLP is not liable for any damages +// arising from the use of this software. +// +// LICENSE RESTRICTIONS: This code may not be modified, copied, or redistributed +// in any form outside of the official GitHub repository at: +// https://github.com/Abblix/OIDC.Server. All development and modifications +// must occur within the official repository and are managed solely by Abblix LLP. +// +// Unauthorized use, modification, or distribution of this software is strictly +// prohibited and may be subject to legal action. +// +// For full licensing terms, please visit: +// +// https://oidc.abblix.com/license +// +// CONTACT: For license inquiries or permissions, contact Abblix LLP at +// info@abblix.com + +using Abblix.Jwt.ReplayPrevention; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Abblix.Jwt.UnitTests.ReplayPrevention; + +/// +/// Pins the replay cache's contract over the store the host supplies: first reservation wins, a +/// repeat is recognised, callers keyed under different prefixes cannot see each other's entries, +/// and the entry lives exactly until the moment the caller named. Honoring that lifetime is the +/// store's own contract and is not re-tested here. +/// +public class DistributedReplayCacheTests +{ + private const string Prefix = "Abblix.Test:ReplayPrevention:"; + + private static readonly DateTimeOffset Now = DateTimeOffset.FromUnixTimeSeconds(1754040000); + + private static IDistributedCache CreateStore() + => new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + + private static DistributedReplayCache CreateCache( + IDistributedCache? store = null, + string prefix = Prefix) + => new(store ?? CreateStore(), new FakeTimeProvider(Now), prefix); + + [Fact] + public async Task FirstReservation_Succeeds_RepeatIsRecognised() + { + var cache = CreateCache(); + + Assert.True(await cache.TryReserveAsync( + "jti-1", Now.AddMinutes(10), TestContext.Current.CancellationToken)); + Assert.False(await cache.TryReserveAsync( + "jti-1", Now.AddMinutes(10), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DistinctIdentifiers_DoNotShadowEachOther() + { + var cache = CreateCache(); + + Assert.True(await cache.TryReserveAsync( + "jti-1", Now.AddMinutes(10), TestContext.Current.CancellationToken)); + Assert.True(await cache.TryReserveAsync( + "jti-2", Now.AddMinutes(10), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SameIdentifier_UnderAnotherPrefix_IsNotAReplay() + { + // The prefix is what lets unrelated profiles share one store: a DPoP proof and a Security + // Event Token may carry the same identifier and neither is replaying the other. + var store = CreateStore(); + var one = CreateCache(store, "Abblix.One:"); + var another = CreateCache(store, "Abblix.Two:"); + + Assert.True(await one.TryReserveAsync( + "jti-1", Now.AddMinutes(10), TestContext.Current.CancellationToken)); + Assert.True(await another.TryReserveAsync( + "jti-1", Now.AddMinutes(10), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task EntryLifetime_RunsUntilTheMomentTheCallerNamed() + { + // What this cache owns is turning an absolute moment into the store's relative lifetime. + // Honoring it is the store's own job, so the recording store only observes what the + // cache asked for. + var store = new RecordingStore(CreateStore()); + var cache = CreateCache(store); + + Assert.True(await cache.TryReserveAsync( + "jti-1", Now.AddMinutes(8), TestContext.Current.CancellationToken)); + + Assert.Equal(TimeSpan.FromMinutes(8), Assert.Single(store.RecordedLifetimes)); + } + + [Fact] + public async Task ExpiryAlreadyPast_StillRecordsTheSighting() + { + // A window that elapsed between validation and this call must not silently reserve + // nothing: the shared primitive floors the lifetime, so the identifier is still taken. + var store = new RecordingStore(CreateStore()); + var cache = CreateCache(store); + + Assert.True(await cache.TryReserveAsync( + "jti-1", Now.AddMinutes(-1), TestContext.Current.CancellationToken)); + + var lifetime = Assert.Single(store.RecordedLifetimes); + Assert.True(lifetime > TimeSpan.Zero, $"A floored lifetime was expected, got {lifetime}."); + } + + [Fact] + public async Task EmptyIdentifier_IsRejected() + { + var cache = CreateCache(); + + await Assert.ThrowsAsync(async () => await cache.TryReserveAsync( + string.Empty, Now.AddMinutes(10), TestContext.Current.CancellationToken)); + } + + /// + /// A pass-through store that records the lifetime each write asked for, so the test can + /// assert the cache's computation without re-implementing the store's expiry. + /// + private sealed class RecordingStore(IDistributedCache inner) : IDistributedCache + { + public List RecordedLifetimes { get; } = []; + + public byte[]? Get(string key) => inner.Get(key); + + public Task GetAsync(string key, CancellationToken token = default) + => inner.GetAsync(key, token); + + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) + { + RecordedLifetimes.Add(options.AbsoluteExpirationRelativeToNow); + inner.Set(key, value, options); + } + + public Task SetAsync( + string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + { + RecordedLifetimes.Add(options.AbsoluteExpirationRelativeToNow); + return inner.SetAsync(key, value, options, token); + } + + public void Refresh(string key) => inner.Refresh(key); + + public Task RefreshAsync(string key, CancellationToken token = default) + => inner.RefreshAsync(key, token); + + public void Remove(string key) => inner.Remove(key); + + public Task RemoveAsync(string key, CancellationToken token = default) + => inner.RemoveAsync(key, token); + } +} diff --git a/tests/Abblix.Oidc.Server.UnitTests/Features/ClientAuthentication/ClientSecretJwtAuthenticatorTests.cs b/tests/Abblix.Oidc.Server.UnitTests/Features/ClientAuthentication/ClientSecretJwtAuthenticatorTests.cs index 043d1cb5..f1d695a7 100644 --- a/tests/Abblix.Oidc.Server.UnitTests/Features/ClientAuthentication/ClientSecretJwtAuthenticatorTests.cs +++ b/tests/Abblix.Oidc.Server.UnitTests/Features/ClientAuthentication/ClientSecretJwtAuthenticatorTests.cs @@ -30,7 +30,7 @@ using Abblix.Utils; using Abblix.Oidc.Server.Features.ClientAuthentication; using Abblix.Oidc.Server.Features.ClientInformation; -using Abblix.Oidc.Server.Features.ReplayPrevention; +using Abblix.Jwt.ReplayPrevention; using Abblix.Oidc.Server.Model; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Time.Testing; @@ -57,7 +57,7 @@ public class ClientSecretJwtAuthenticatorTests private readonly Mock _clientInfoProvider; private readonly Mock _requestInfoProvider; private readonly FakeTimeProvider _clock; - private readonly Mock _replayCache; + private readonly Mock _replayCache; private readonly ClientSecretJwtAuthenticator _authenticator; public ClientSecretJwtAuthenticatorTests() @@ -67,7 +67,7 @@ public ClientSecretJwtAuthenticatorTests() _clientInfoProvider = new Mock(MockBehavior.Strict); _requestInfoProvider = new Mock(MockBehavior.Strict); _clock = new FakeTimeProvider(); - _replayCache = new Mock(MockBehavior.Strict); + _replayCache = new Mock(MockBehavior.Strict); _requestInfoProvider .Setup(p => p.RequestUri) @@ -209,7 +209,7 @@ public async Task TryAuthenticateClientAsync_WithValidHS256Jwt_ShouldAuthenticat .ReturnsAsync(clientInfo); _replayCache - .Setup(r => r.TryAddAsync(jwtId, It.IsAny())) + .Setup(r => r.TryReserveAsync(jwtId, It.IsAny())) .ReturnsAsync(true); var request = new ClientRequest @@ -225,7 +225,7 @@ public async Task TryAuthenticateClientAsync_WithValidHS256Jwt_ShouldAuthenticat Assert.NotNull(result); Assert.Equal(ClientId, result.ClientId); - _replayCache.Verify(r => r.TryAddAsync(jwtId, It.IsAny()), Times.Once); + _replayCache.Verify(r => r.TryReserveAsync(jwtId, It.IsAny()), Times.Once); } /// @@ -276,7 +276,7 @@ public async Task TryAuthenticateClientAsync_WithMismatchedSigningAlg_ShouldRetu // Assert Assert.Null(result); _replayCache.Verify( - r => r.TryAddAsync(It.IsAny(), It.IsAny()), + r => r.TryReserveAsync(It.IsAny(), It.IsAny()), Times.Never); } @@ -316,7 +316,7 @@ public async Task TryAuthenticateClientAsync_WithMatchingSigningAlg_ShouldAuthen .ReturnsAsync(clientInfo); _replayCache - .Setup(r => r.TryAddAsync(jwtId, It.IsAny())) + .Setup(r => r.TryReserveAsync(jwtId, It.IsAny())) .ReturnsAsync(true); var request = new ClientRequest @@ -520,7 +520,7 @@ public async Task TryAuthenticateClientAsync_WithoutJti_ShouldReturnNull() Assert.Null(result); // A rejected assertion is never recorded in the replay cache. - _replayCache.Verify(r => r.TryAddAsync(It.IsAny(), It.IsAny()), Times.Never); + _replayCache.Verify(r => r.TryReserveAsync(It.IsAny(), It.IsAny()), Times.Never); } /// diff --git a/tests/Abblix.Oidc.Server.UnitTests/Features/ClientAuthentication/PrivateKeyJwtAuthenticatorTests.cs b/tests/Abblix.Oidc.Server.UnitTests/Features/ClientAuthentication/PrivateKeyJwtAuthenticatorTests.cs index f11b0bc7..49e0396b 100644 --- a/tests/Abblix.Oidc.Server.UnitTests/Features/ClientAuthentication/PrivateKeyJwtAuthenticatorTests.cs +++ b/tests/Abblix.Oidc.Server.UnitTests/Features/ClientAuthentication/PrivateKeyJwtAuthenticatorTests.cs @@ -28,7 +28,7 @@ using Abblix.Oidc.Server.Common.Constants; using Abblix.Oidc.Server.Features.ClientAuthentication; using Abblix.Oidc.Server.Features.ClientInformation; -using Abblix.Oidc.Server.Features.ReplayPrevention; +using Abblix.Jwt.ReplayPrevention; using Abblix.Oidc.Server.Features.Tokens.Validation; using Abblix.Oidc.Server.Model; using Microsoft.Extensions.DependencyInjection; @@ -416,16 +416,16 @@ public async Task ValidJwtWithJtiAndExp_ShouldRecordInReplayCache() // Assert Assert.NotNull(result); mocks.ReplayCache.Verify( - r => r.TryAddAsync( + r => r.TryReserveAsync( It.Is(id => id == jti), - It.Is(exp => - exp.HasValue && Math.Abs((exp.Value - expiresAt).TotalSeconds) < 1)), + It.Is(exp => + Math.Abs((exp - expiresAt).TotalSeconds) < 1)), Times.Once); } /// /// Verifies that a replayed assertion is rejected: the replay cache reports the jti as - /// already present, and the single TryAddAsync call makes the reserve-and-check atomic - + /// already present, and the single TryReserveAsync call makes the reserve-and-check atomic - /// two concurrent presenters of the same assertion cannot both pass. /// [Fact] @@ -444,7 +444,7 @@ public async Task ReplayedAssertion_ShouldReturnNull() .ReturnsAsync(new ValidJsonWebToken(validToken, clientInfo)); mocks.ReplayCache - .Setup(r => r.TryAddAsync("replayed-jti", It.IsAny())) + .Setup(r => r.TryReserveAsync("replayed-jti", It.IsAny())) .ReturnsAsync(false); var request = new ClientRequest @@ -492,7 +492,7 @@ public async Task AssertionWithoutJti_ShouldReturnNull() Assert.Null(result); // A rejected assertion is never recorded in the replay cache. mocks.ReplayCache.Verify( - r => r.TryAddAsync(It.IsAny(), It.IsAny()), + r => r.TryReserveAsync(It.IsAny(), It.IsAny()), Times.Never); } @@ -573,12 +573,12 @@ public void ClientAuthenticationMethodsSupported_ShouldReturnPrivateKeyJwt() private (PrivateKeyJwtAuthenticator authenticator, Mocks mocks) CreateAuthenticator() { var logger = new Mock>(); - var replayCache = new Mock(MockBehavior.Strict); + var replayCache = new Mock(MockBehavior.Strict); var clientJwtValidator = new Mock(MockBehavior.Strict); // Setup default behavior for the replay cache: every jti is fresh replayCache - .Setup(r => r.TryAddAsync(It.IsAny(), It.IsAny())) + .Setup(r => r.TryReserveAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(true); // Create service provider with scoped services @@ -714,7 +714,7 @@ private JsonWebToken CreateValidJwtTokenWithJtiAndExp( private sealed class Mocks { public Mock> Logger { get; init; } = null!; - public Mock ReplayCache { get; init; } = null!; + public Mock ReplayCache { get; init; } = null!; public Mock ClientJwtValidator { get; init; } = null!; } } diff --git a/tests/Abblix.Oidc.Server.UnitTests/Features/DPoP/ProofValidatorTests.cs b/tests/Abblix.Oidc.Server.UnitTests/Features/DPoP/ProofValidatorTests.cs index 0824ccf9..01662983 100644 --- a/tests/Abblix.Oidc.Server.UnitTests/Features/DPoP/ProofValidatorTests.cs +++ b/tests/Abblix.Oidc.Server.UnitTests/Features/DPoP/ProofValidatorTests.cs @@ -69,7 +69,9 @@ public ProofValidatorTests() services.Configure(_ => { }); services.AddSingleton(_time); services.AddSingleton(_requestInfo.Object); - services.AddSingleton(); + // Through the library's own wiring rather than a hand-picked implementation, so this + // suite exercises the replay cache a deployment actually gets, decorator included. + services.AddReplayPrevention(); services.AddSingleton(); var sp = services.BuildServiceProvider(); _sut = sp.GetRequiredService(); diff --git a/tests/Abblix.Oidc.Server.UnitTests/Features/DependencyInjection/ServiceCollectionOverrideTests.cs b/tests/Abblix.Oidc.Server.UnitTests/Features/DependencyInjection/ServiceCollectionOverrideTests.cs index ac6498fc..32978196 100644 --- a/tests/Abblix.Oidc.Server.UnitTests/Features/DependencyInjection/ServiceCollectionOverrideTests.cs +++ b/tests/Abblix.Oidc.Server.UnitTests/Features/DependencyInjection/ServiceCollectionOverrideTests.cs @@ -27,6 +27,7 @@ using Abblix.Jwt; using Abblix.Jwt.Encryption; +using Abblix.Jwt.ReplayPrevention; using Abblix.Jwt.Signing; using Abblix.Oidc.Server.AspNetCore; @@ -38,6 +39,7 @@ using Abblix.Oidc.Server.Features.ClientAuthentication; using Abblix.Oidc.Server.Features.ClientInformation; using Abblix.Oidc.Server.Features.DPoP; +using Abblix.Oidc.Server.Features.ReplayPrevention; using Abblix.Oidc.Server.Features.RichAuthorizationRequests; using Abblix.Oidc.Server.Features.Tokens.Formatters; using Abblix.Oidc.Server.Features.Tokens.Validation; @@ -168,6 +170,57 @@ public void AddDPoP_InvokedTwice_DefaultsRegisteredOnce() Assert.Single(services, d => d.ServiceType == typeof(IProofValidator)); } + [Fact] + public async Task AddDPoP_HostImplementedTheDeprecatedReplayCache_StaysInCharge() + { + // The contract moved to Abblix.JWT and the server's own consumers moved with it. A host + // that had replaced the deprecated one - to get a strictly atomic backend, the reason the + // seam exists - must not have that silently stop applying while its registration still + // looks healthy: the bridge is what keeps the override deciding. + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDistributedMemoryCache(); + services.Configure(_ => { }); + +#pragma warning disable CS0618 // the deprecated contract is exactly what this test covers + var host = new Mock(); + host.Setup(cache => cache.TryAddAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + services.AddSingleton(host.Object); +#pragma warning restore CS0618 + + services.AddDPoP(); + + await using var provider = services.BuildServiceProvider(); + var reserved = await provider.GetRequiredService() + .TryReserveAsync( + "jti-1", + DateTimeOffset.FromUnixTimeSeconds(1754040000), + TestContext.Current.CancellationToken); + + Assert.False(reserved); +#pragma warning disable CS0618 // the deprecated contract is exactly what this test covers + host.Verify( + cache => cache.TryAddAsync("jti-1", It.IsAny()), + Times.Once); +#pragma warning restore CS0618 + } + + [Fact] + public void AddDPoP_AndClientAuthentication_RegisterOneReplayCache() + { + // Three feature registrations wire replay prevention, and the server's policy is applied + // by decoration - so a second call must not wrap the cache again, which would skew the + // retention window twice over and log every reservation twice. + var services = new ServiceCollection(); + + services.AddDPoP(); + services.AddClientAuthentication(); + services.AddDPoP(); + + Assert.Single(services, d => d.ServiceType == typeof(IReplayCache)); + } + [Fact] public void AddClientInformation_HostPreregisteredClientInfoProvider_Wins() { diff --git a/tests/Abblix.SecurityEvents.UnitTests/DistributedJtiReplayCacheTests.cs b/tests/Abblix.SecurityEvents.UnitTests/DistributedJtiReplayCacheTests.cs deleted file mode 100644 index 62de1447..00000000 --- a/tests/Abblix.SecurityEvents.UnitTests/DistributedJtiReplayCacheTests.cs +++ /dev/null @@ -1,148 +0,0 @@ -// Abblix OIDC Server Library -// Copyright (c) Abblix LLP. All rights reserved. -// -// DISCLAIMER: This software is provided 'as-is', without any express or implied -// warranty. Use at your own risk. Abblix LLP is not liable for any damages -// arising from the use of this software. -// -// LICENSE RESTRICTIONS: This code may not be modified, copied, or redistributed -// in any form outside of the official GitHub repository at: -// https://github.com/Abblix/OIDC.Server. All development and modifications -// must occur within the official repository and are managed solely by Abblix LLP. -// -// Unauthorized use, modification, or distribution of this software is strictly -// prohibited and may be subject to legal action. -// -// For full licensing terms, please visit: -// -// https://oidc.abblix.com/license -// -// CONTACT: For license inquiries or permissions, contact Abblix LLP at -// info@abblix.com - -using Abblix.SecurityEvents.Infrastructure; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; -using Microsoft.Extensions.Time.Testing; -using Xunit; - -namespace Abblix.SecurityEvents.UnitTests; - -/// -/// Pins the replay cache's contract over the store the host supplies: first registration wins, a -/// repeat is recognised, feeds are isolated by issuer even against adversarial key material, and -/// the entry lives exactly until its token's issue time plus the retention. Honoring that lifetime -/// is the store's own contract and is not re-tested here. -/// -public class DistributedJtiReplayCacheTests -{ - private static readonly DateTimeOffset Now = DateTimeOffset.FromUnixTimeSeconds(1754040000); - - private static IDistributedCache CreateStore() - => new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); - - [Fact] - public async Task FirstRegistration_Succeeds_RepeatIsRecognised() - { - var cache = new DistributedJtiReplayCache( - CreateStore(), new FakeTimeProvider(Now), TimeSpan.FromMinutes(10)); - - Assert.True(await cache.TryRegisterAsync( - "https://issuer.example.com", "jti-1", Now, TestContext.Current.CancellationToken)); - Assert.False(await cache.TryRegisterAsync( - "https://issuer.example.com", "jti-1", Now, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task SameIdentifier_FromAnotherIssuer_IsNotAReplay() - { - // "jti" is unique "within a particular event feed" (RFC 8417 Section 2.2): two issuers - // may mint the same identifier and neither is replaying the other. - var cache = new DistributedJtiReplayCache( - CreateStore(), new FakeTimeProvider(Now), TimeSpan.FromMinutes(10)); - - Assert.True(await cache.TryRegisterAsync( - "https://one.example.com", "jti-1", Now, TestContext.Current.CancellationToken)); - Assert.True(await cache.TryRegisterAsync( - "https://two.example.com", "jti-1", Now, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task AdjacentIssuerAndIdentifier_DoNotCollideOnOneKey() - { - // A naive "issuer + separator + jti" key would map both pairs below onto one string, - // letting a token from one feed shadow a token from another. Escaping keeps the - // separator unambiguous, so the pairs stay distinct entries. - var cache = new DistributedJtiReplayCache( - CreateStore(), new FakeTimeProvider(Now), TimeSpan.FromMinutes(10)); - - Assert.True(await cache.TryRegisterAsync( - "https://t.example.com", "a:b", Now, TestContext.Current.CancellationToken)); - Assert.True(await cache.TryRegisterAsync( - "https://t.example.com:a", "b", Now, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task EntryLifetime_IsTheTokenIssueTimePlusTheRetention() - { - // What this cache owns is the lifetime computation; the store owns honoring it. The - // recording store observes what the cache asked for: a token issued two minutes ago - // under a ten-minute retention has eight minutes left to be replayed. - var store = new RecordingStore(CreateStore()); - var retention = TimeSpan.FromMinutes(10); - var issuedAt = Now - TimeSpan.FromMinutes(2); - var cache = new DistributedJtiReplayCache(store, new FakeTimeProvider(Now), retention); - - Assert.True(await cache.TryRegisterAsync( - "https://issuer.example.com", "jti-1", issuedAt, TestContext.Current.CancellationToken)); - - Assert.Equal( - issuedAt + retention - Now, - Assert.Single(store.RecordedLifetimes)); - } - - [Fact] - public void ZeroRetention_IsRejected() - { - Assert.Throws(() => new DistributedJtiReplayCache( - CreateStore(), new FakeTimeProvider(Now), TimeSpan.Zero)); - } - - /// - /// A pass-through store that records the lifetime each write asked for, so the test can - /// assert the cache's computation without re-implementing the store's expiry. - /// - private sealed class RecordingStore(IDistributedCache inner) : IDistributedCache - { - public List RecordedLifetimes { get; } = []; - - public byte[]? Get(string key) => inner.Get(key); - - public Task GetAsync(string key, CancellationToken token = default) - => inner.GetAsync(key, token); - - public void Set(string key, byte[] value, DistributedCacheEntryOptions options) - { - RecordedLifetimes.Add(options.AbsoluteExpirationRelativeToNow); - inner.Set(key, value, options); - } - - public Task SetAsync( - string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) - { - RecordedLifetimes.Add(options.AbsoluteExpirationRelativeToNow); - return inner.SetAsync(key, value, options, token); - } - - public void Refresh(string key) => inner.Refresh(key); - - public Task RefreshAsync(string key, CancellationToken token = default) - => inner.RefreshAsync(key, token); - - public void Remove(string key) => inner.Remove(key); - - public Task RemoveAsync(string key, CancellationToken token = default) - => inner.RemoveAsync(key, token); - } -} diff --git a/tests/Abblix.SecurityEvents.UnitTests/TransmitterToReceiverScenarioTests.cs b/tests/Abblix.SecurityEvents.UnitTests/TransmitterToReceiverScenarioTests.cs index 6d36db27..11d59dbe 100644 --- a/tests/Abblix.SecurityEvents.UnitTests/TransmitterToReceiverScenarioTests.cs +++ b/tests/Abblix.SecurityEvents.UnitTests/TransmitterToReceiverScenarioTests.cs @@ -24,6 +24,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Abblix.Jwt; +using Abblix.Jwt.ReplayPrevention; using Abblix.SecurityEvents.Abstractions; using Abblix.SecurityEvents.Events; using Abblix.SecurityEvents.Infrastructure; @@ -143,8 +144,10 @@ private static async Task ReceiveAndProcess( Assert.True(result.TryGetSuccess(out var validated), "Validation unexpectedly failed."); var token = validated.Token; - var isFirstDelivery = await receiver.GetRequiredService().TryRegisterAsync( - token.Issuer!, token.JwtId!, token.IssuedAt!.Value, TestContext.Current.CancellationToken); + var isFirstDelivery = await receiver.GetRequiredService().TryReserveAsync( + $"{token.Issuer}:{token.JwtId}", + token.IssuedAt!.Value + TimeSpan.FromMinutes(10), + TestContext.Current.CancellationToken); if (isFirstDelivery) { diff --git a/tests/Abblix.SharedSignals.UnitTests/PushDeliveryHandlerTests.cs b/tests/Abblix.SharedSignals.UnitTests/PushDeliveryHandlerTests.cs index 10dc22de..c90a2e33 100644 --- a/tests/Abblix.SharedSignals.UnitTests/PushDeliveryHandlerTests.cs +++ b/tests/Abblix.SharedSignals.UnitTests/PushDeliveryHandlerTests.cs @@ -22,7 +22,7 @@ using System.Net; using Abblix.SecurityEvents; -using Abblix.SecurityEvents.Abstractions; +using Abblix.Jwt.ReplayPrevention; using Abblix.SecurityEvents.Delivery; using Abblix.SecurityEvents.Validation; using Abblix.SharedSignals.Receiver; @@ -75,16 +75,15 @@ private sealed class RecordingSink(DeliveryError? refusal = null) : ISecurityEve } } - private sealed class FakeReplayCache : IJtiReplayCache + private sealed class FakeReplayCache : IReplayCache { - private readonly HashSet<(string, string)> _seen = []; + private readonly HashSet _seen = []; - public Task TryRegisterAsync( - string issuer, - string jwtId, - DateTimeOffset issuedAt, + public Task TryReserveAsync( + string identifier, + DateTimeOffset expiresAt, CancellationToken cancellationToken = default) - => Task.FromResult(_seen.Add((issuer, jwtId))); + => Task.FromResult(_seen.Add(identifier)); } [Fact]