Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/Abblix.DependencyInjection/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<IJtiReplayCache>(provider =>
provider.CreateService<DistributedJtiReplayCache>(
Dependency.Override(TimeSpan.FromMinutes(10))));
services.AddSingleton<IReplayCache>(provider =>
provider.CreateService<DistributedReplayCache>(
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.
Expand Down
5 changes: 4 additions & 1 deletion src/Abblix.Jwt/Abblix.Jwt.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@

<ItemGroup>
<!-- Hosting.Abstractions carries only IHostedService and BackgroundService, which the key ring's refresh
service needs. Options carries IOptions<>, which the ring reads its rollover window from. -->
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. -->
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
Expand Down
11 changes: 11 additions & 0 deletions src/Abblix.Jwt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<IReplayCache>(provider =>
provider.CreateService<DistributedReplayCache>(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`.
Expand Down
74 changes: 74 additions & 0 deletions src/Abblix.Jwt/ReplayPrevention/DistributedReplayCache.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A replay cache over the host's <see cref="IDistributedCache"/>: 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.
/// </summary>
/// <remarks>
/// 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
/// <see cref="IDistributedCache"/> 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 <see cref="IReplayCache"/>.
/// </remarks>
/// <param name="cache">The distributed cache the host registered; the store is the host's choice.
/// </param>
/// <param name="clock">The clock the retention window is measured against.</param>
/// <param name="keyPrefix">
/// 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.</param>
public sealed class DistributedReplayCache(
IDistributedCache cache,
TimeProvider clock,
string keyPrefix) : IReplayCache
{
private readonly string _keyPrefix = keyPrefix
?? throw new ArgumentNullException(nameof(keyPrefix));

/// <inheritdoc />
public async Task<bool> 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);
}
}
63 changes: 63 additions & 0 deletions src/Abblix.Jwt/ReplayPrevention/IReplayCache.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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
/// <see cref="DistributedReplayCache"/> rides <c>IDistributedCache</c>, 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 <c>SET NX EX</c>, SQL <c>INSERT ... ON CONFLICT DO NOTHING</c>, and their equivalents.
/// </remarks>
public interface IReplayCache
{
/// <summary>
/// Reserves an identifier, answering whether this is its first sighting.
/// </summary>
/// <param name="identifier">
/// 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.
/// </param>
/// <param name="expiresAt">
/// 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.</param>
/// <param name="cancellationToken">Cancels the cache round trip.</param>
/// <returns>
/// True when the identifier was newly reserved and the token is therefore fresh; false when
/// it was already there, which is a replay.</returns>
Task<bool> TryReserveAsync(
string identifier,
DateTimeOffset expiresAt,
CancellationToken cancellationToken = default);
}
12 changes: 5 additions & 7 deletions src/Abblix.Oidc.Server/Endpoints/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -316,12 +315,11 @@ public static IServiceCollection EnablePasswordGrant(this IServiceCollection ser
public static IServiceCollection AddJwtBearerGrant(this IServiceCollection services)
{
services.TryAddSingleton<IJwtBearerIssuerProvider, JwtBearerIssuerProvider>();
services.TryAddSingleton<IJwtReplayCache, DistributedJwtReplayCache>();
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<JwtBearer.IJwtReplayCache, JwtBearer.DistributedJwtReplayCache>();
#pragma warning restore CS0618
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -50,7 +50,7 @@ public partial class ClientSecretJwtAuthenticator(
IClientInfoProvider clientInfoProvider,
IRequestInfoProvider requestInfoProvider,
TimeProvider clock,
IJwtReplayCache replayCache) : JwtAssertionAuthenticatorBase(logger, replayCache)
IReplayCache replayCache) : JwtAssertionAuthenticatorBase(logger, replayCache)
{
/// <summary>
/// Specifies the client authentication method this authenticator supports, which is 'client_secret_jwt'.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -39,7 +39,7 @@ namespace Abblix.Oidc.Server.Features.ClientAuthentication;
/// <param name="replayCache">Replay cache that records assertion jti values and atomically rejects reuse.</param>
public abstract partial class JwtAssertionAuthenticatorBase(
ILogger logger,
IJwtReplayCache replayCache) : IClientAuthenticator
IReplayCache replayCache) : IClientAuthenticator
{
/// <summary>
/// Specifies the client authentication methods supported by this authenticator.
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -39,7 +39,7 @@ namespace Abblix.Oidc.Server.Features.ClientAuthentication;
/// <param name="serviceProvider">Service provider used to resolve scoped dependencies.</param>
public class PrivateKeyJwtAuthenticator(
ILogger<PrivateKeyJwtAuthenticator> logger,
IJwtReplayCache replayCache,
IReplayCache replayCache,
IServiceProvider serviceProvider) : JwtAssertionAuthenticatorBase(logger, replayCache)
{
/// <summary>
Expand Down
9 changes: 5 additions & 4 deletions src/Abblix.Oidc.Server/Features/DPoP/ProofValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -51,7 +51,7 @@ namespace Abblix.Oidc.Server.Features.DPoP;
/// </remarks>
internal sealed class ProofValidator(
IJsonWebTokenValidator jwtValidator,
IJwtReplayCache replayCache,
IReplayCache replayCache,
IOptionsMonitor<OidcOptions> options,
IRequestInfoProvider requestInfoProvider,
TimeProvider timeProvider) : IProofValidator
Expand Down Expand Up @@ -113,9 +113,10 @@ public async Task<Result<Proof, ProofError>> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,11 +39,14 @@ namespace Abblix.Oidc.Server.Features.JwtBearer;
/// <param name="oidcOptions">OIDC configuration options containing JWT Bearer trusted issuers.</param>
/// <param name="replayCache">Cache for JWT replay protection per RFC 7523 Section 5.2.</param>
/// <param name="secureFetcher">HTTP fetcher with SSRF protection and caching.</param>
/// <param name="timeProvider">Dates the fallback retention window for an assertion without an
/// expiry.</param>
public partial class JwtBearerIssuerProvider(
ILogger<JwtBearerIssuerProvider> logger,
IOptionsMonitor<OidcOptions> oidcOptions,
ReplayPrevention.IJwtReplayCache replayCache,
[FromKeyedServices(KeySetOwners.Issuer)] ISecureHttpFetcher secureFetcher) : IJwtBearerIssuerProvider
IReplayCache replayCache,
[FromKeyedServices(KeySetOwners.Issuer)] ISecureHttpFetcher secureFetcher,
TimeProvider timeProvider) : IJwtBearerIssuerProvider
{
/// <inheritdoc />
public JwtBearerOptions Options => oidcOptions.CurrentValue.JwtBearer;
Expand Down Expand Up @@ -130,5 +134,10 @@ public async IAsyncEnumerable<JsonWebKey> GetSigningKeysAsync(string issuer)

/// <inheritdoc />
public async Task<bool> 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading