diff --git a/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs b/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs
new file mode 100644
index 000000000000..ecb2f36cece0
--- /dev/null
+++ b/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs
@@ -0,0 +1,102 @@
+using Bit.Sso.Utilities;
+using Duende.IdentityServer.Models;
+using Duende.IdentityServer.Stores;
+using ZiggyCreatures.Caching.Fusion;
+
+namespace Bit.Sso.IdentityServer;
+
+///
+/// Distributed cache-backed persisted grant store for short-lived grants.
+/// Uses IFusionCache (which wraps IDistributedCache) for horizontal scaling support,
+/// and fall back to in-memory caching if Redis is not configured.
+/// Designed for SSO authorization codes which are short-lived (5 minutes) and single-use.
+///
+///
+/// This is purposefully a different implementation from how Identity solves Persisted Grants.
+/// Because even flavored grant store, e.g., AuthorizationCodeGrantStore, can add intermediary
+/// logic to a grant's handling by type, the fact that they all wrap IdentityServer's IPersistedGrantStore
+/// leans on IdentityServer's opinion that all grants, regardless of type, go to the same persistence
+/// mechanism (cache, database).
+///
+///
+public class DistributedCachePersistedGrantStore : IPersistedGrantStore
+{
+ private readonly IFusionCache _cache;
+
+ public DistributedCachePersistedGrantStore(
+ [FromKeyedServices(PersistedGrantsDistributedCacheConstants.CacheKey)] IFusionCache cache)
+ {
+ _cache = cache;
+ }
+
+ public async Task GetAsync(string key)
+ {
+ var result = await _cache.TryGetAsync(key);
+
+ if (!result.HasValue)
+ {
+ return null;
+ }
+
+ var grant = result.Value;
+
+ // Check if grant has expired - remove expired grants from cache
+ if (grant.Expiration.HasValue && grant.Expiration.Value < DateTime.UtcNow)
+ {
+ await RemoveAsync(key);
+ return null;
+ }
+
+ return grant;
+ }
+
+ public Task> GetAllAsync(PersistedGrantFilter filter)
+ {
+ // Cache stores are key-value based and don't support querying by filter criteria.
+ // This method is typically used for cleanup operations on long-lived grants in databases.
+ // For SSO's short-lived authorization codes, we rely on TTL expiration instead.
+
+ return Task.FromResult(Enumerable.Empty());
+ }
+
+ public Task RemoveAllAsync(PersistedGrantFilter filter)
+ {
+ // Revocation Strategy: SSO's logout flow (AccountController.LogoutAsync) only clears local
+ // authentication cookies and performs federated logout with external IdPs. It does not invoke
+ // Duende's EndSession or TokenRevocation endpoints. Authorization codes are single-use and expire
+ // within 5 minutes, making explicit revocation unnecessary for SSO's security model.
+ // https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/
+
+ // Cache stores are key-value based and don't support bulk deletion by filter.
+ // This method is typically used for cleanup operations on long-lived grants in databases.
+ // For SSO's short-lived authorization codes, we rely on TTL expiration instead.
+
+ return Task.FromResult(0);
+ }
+
+ public async Task RemoveAsync(string key)
+ {
+ await _cache.RemoveAsync(key);
+ }
+
+ public async Task StoreAsync(PersistedGrant grant)
+ {
+ // Calculate TTL based on grant expiration
+ var duration = grant.Expiration.HasValue
+ ? grant.Expiration.Value - DateTime.UtcNow
+ : TimeSpan.FromMinutes(5); // Default to 5 minutes if no expiration set
+
+ // Ensure positive duration
+ if (duration <= TimeSpan.Zero)
+ {
+ return;
+ }
+
+ // Cache key "sso-grants:" is configured by service registration. Going through the consumed KeyedService will
+ // give us a consistent cache key prefix for these grants.
+ await _cache.SetAsync(
+ grant.Key,
+ grant,
+ new FusionCacheEntryOptions { Duration = duration });
+ }
+}
diff --git a/bitwarden_license/src/Sso/Utilities/PersistedGrantsDistributedCacheConstants.cs b/bitwarden_license/src/Sso/Utilities/PersistedGrantsDistributedCacheConstants.cs
new file mode 100644
index 000000000000..3ec45377e3d8
--- /dev/null
+++ b/bitwarden_license/src/Sso/Utilities/PersistedGrantsDistributedCacheConstants.cs
@@ -0,0 +1,10 @@
+namespace Bit.Sso.Utilities;
+
+public static class PersistedGrantsDistributedCacheConstants
+{
+ ///
+ /// The SSO Persisted Grant cache key. Identifies the keyed service consumed by the SSO Persisted Grant Store as
+ /// well as the cache key/namespace for grant storage.
+ ///
+ public const string CacheKey = "sso-grants";
+}
diff --git a/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs
index a51a04f5c85b..da7a79535ea2 100644
--- a/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs
+++ b/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs
@@ -9,6 +9,7 @@
using Bit.Sso.Models;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.ResponseHandling;
+using Duende.IdentityServer.Stores;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Sustainsys.Saml2.AspNetCore2;
@@ -77,6 +78,17 @@ public static IIdentityServerBuilder AddSsoIdentityServerServices(this IServiceC
})
.AddIdentityServerCertificate(env, globalSettings);
+ // PM-23572
+ // Register named FusionCache for SSO authorization code grants.
+ // Provides separation of concerns and automatic Redis/in-memory negotiation
+ // .AddInMemoryCaching should still persist above; this handles configuration caching, etc.,
+ // and is separate from this keyed service, which only serves grant negotiation.
+ services.AddExtendedCache(PersistedGrantsDistributedCacheConstants.CacheKey, globalSettings);
+
+ // Store authorization codes in distributed cache for horizontal scaling
+ // Uses named FusionCache which gracefully degrades to in-memory when Redis isn't configured
+ services.AddSingleton();
+
return identityServerBuilder;
}
}
diff --git a/bitwarden_license/test/SSO.Test/IdentityServer/DistributedCachePersistedGrantStoreTests.cs b/bitwarden_license/test/SSO.Test/IdentityServer/DistributedCachePersistedGrantStoreTests.cs
new file mode 100644
index 000000000000..c0aa93f0683f
--- /dev/null
+++ b/bitwarden_license/test/SSO.Test/IdentityServer/DistributedCachePersistedGrantStoreTests.cs
@@ -0,0 +1,257 @@
+using Bit.Sso.IdentityServer;
+using Duende.IdentityServer.Models;
+using Duende.IdentityServer.Stores;
+using NSubstitute;
+using ZiggyCreatures.Caching.Fusion;
+
+namespace Bit.SSO.Test.IdentityServer;
+
+public class DistributedCachePersistedGrantStoreTests
+{
+ private readonly IFusionCache _cache;
+ private readonly DistributedCachePersistedGrantStore _sut;
+
+ public DistributedCachePersistedGrantStoreTests()
+ {
+ _cache = Substitute.For();
+ _sut = new DistributedCachePersistedGrantStore(_cache);
+ }
+
+ [Fact]
+ public async Task StoreAsync_StoresGrantWithCalculatedTTL()
+ {
+ // Arrange
+ var grant = CreateTestGrant("test-key", expiration: DateTime.UtcNow.AddMinutes(5));
+
+ // Act
+ await _sut.StoreAsync(grant);
+
+ // Assert
+ await _cache.Received(1).SetAsync(
+ "test-key",
+ grant,
+ Arg.Is(opts =>
+ opts.Duration >= TimeSpan.FromMinutes(4.9) &&
+ opts.Duration <= TimeSpan.FromMinutes(5)));
+ }
+
+ [Fact]
+ public async Task StoreAsync_WithNoExpiration_UsesDefaultFiveMinuteTTL()
+ {
+ // Arrange
+ var grant = CreateTestGrant("no-expiry-key", expiration: null);
+
+ // Act
+ await _sut.StoreAsync(grant);
+
+ // Assert
+ await _cache.Received(1).SetAsync(
+ "no-expiry-key",
+ grant,
+ Arg.Is(opts => opts.Duration == TimeSpan.FromMinutes(5)));
+ }
+
+ [Fact]
+ public async Task StoreAsync_WithAlreadyExpiredGrant_DoesNotStore()
+ {
+ // Arrange
+ var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
+
+ // Act
+ await _sut.StoreAsync(expiredGrant);
+
+ // Assert
+ await _cache.DidNotReceive().SetAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task StoreAsync_EnablesDistributedCache()
+ {
+ // Arrange
+ var grant = CreateTestGrant("distributed-key", expiration: DateTime.UtcNow.AddMinutes(5));
+
+ // Act
+ await _sut.StoreAsync(grant);
+
+ // Assert
+ await _cache.Received(1).SetAsync(
+ "distributed-key",
+ grant,
+ Arg.Is(opts =>
+ opts.SkipDistributedCache == false &&
+ opts.SkipDistributedCacheReadWhenStale == false));
+ }
+
+ [Fact]
+ public async Task GetAsync_WithValidGrant_ReturnsGrant()
+ {
+ // Arrange
+ var grant = CreateTestGrant("valid-key", expiration: DateTime.UtcNow.AddMinutes(5));
+ _cache.TryGetAsync("valid-key")
+ .Returns(MaybeValue.FromValue(grant));
+
+ // Act
+ var result = await _sut.GetAsync("valid-key");
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal("valid-key", result.Key);
+ Assert.Equal("authorization_code", result.Type);
+ Assert.Equal("test-subject", result.SubjectId);
+ await _cache.DidNotReceive().RemoveAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetAsync_WithNonExistentKey_ReturnsNull()
+ {
+ // Arrange
+ _cache.TryGetAsync("nonexistent-key")
+ .Returns(MaybeValue.None);
+
+ // Act
+ var result = await _sut.GetAsync("nonexistent-key");
+
+ // Assert
+ Assert.Null(result);
+ await _cache.DidNotReceive().RemoveAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetAsync_WithExpiredGrant_RemovesAndReturnsNull()
+ {
+ // Arrange
+ var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
+ _cache.TryGetAsync("expired-key")
+ .Returns(MaybeValue.FromValue(expiredGrant));
+
+ // Act
+ var result = await _sut.GetAsync("expired-key");
+
+ // Assert
+ Assert.Null(result);
+ await _cache.Received(1).RemoveAsync("expired-key");
+ }
+
+ [Fact]
+ public async Task GetAsync_WithNoExpiration_ReturnsGrant()
+ {
+ // Arrange
+ var grant = CreateTestGrant("no-expiry-key", expiration: null);
+ _cache.TryGetAsync("no-expiry-key")
+ .Returns(MaybeValue.FromValue(grant));
+
+ // Act
+ var result = await _sut.GetAsync("no-expiry-key");
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal("no-expiry-key", result.Key);
+ Assert.Null(result.Expiration);
+ await _cache.DidNotReceive().RemoveAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task RemoveAsync_RemovesGrantFromCache()
+ {
+ // Act
+ await _sut.RemoveAsync("remove-key");
+
+ // Assert
+ await _cache.Received(1).RemoveAsync("remove-key");
+ }
+
+ [Fact]
+ public async Task GetAllAsync_ReturnsEmptyCollection()
+ {
+ // Arrange
+ var filter = new PersistedGrantFilter
+ {
+ SubjectId = "test-subject",
+ SessionId = "test-session",
+ ClientId = "test-client",
+ Type = "authorization_code"
+ };
+
+ // Act
+ var result = await _sut.GetAllAsync(filter);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public async Task RemoveAllAsync_CompletesWithoutError()
+ {
+ // Arrange
+ var filter = new PersistedGrantFilter
+ {
+ SubjectId = "test-subject",
+ ClientId = "test-client"
+ };
+
+ // Act & Assert - should not throw
+ await _sut.RemoveAllAsync(filter);
+
+ // Verify no cache operations were performed
+ await _cache.DidNotReceive().RemoveAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task StoreAsync_PreservesAllGrantProperties()
+ {
+ // Arrange
+ var grant = new PersistedGrant
+ {
+ Key = "full-grant-key",
+ Type = "authorization_code",
+ SubjectId = "user-123",
+ SessionId = "session-456",
+ ClientId = "client-789",
+ Description = "Test grant",
+ CreationTime = DateTime.UtcNow.AddMinutes(-1),
+ Expiration = DateTime.UtcNow.AddMinutes(5),
+ ConsumedTime = null,
+ Data = "{\"test\":\"data\"}"
+ };
+
+ PersistedGrant? capturedGrant = null;
+ await _cache.SetAsync(
+ Arg.Any(),
+ Arg.Do(g => capturedGrant = g),
+ Arg.Any());
+
+ // Act
+ await _sut.StoreAsync(grant);
+
+ // Assert
+ Assert.NotNull(capturedGrant);
+ Assert.Equal(grant.Key, capturedGrant.Key);
+ Assert.Equal(grant.Type, capturedGrant.Type);
+ Assert.Equal(grant.SubjectId, capturedGrant.SubjectId);
+ Assert.Equal(grant.SessionId, capturedGrant.SessionId);
+ Assert.Equal(grant.ClientId, capturedGrant.ClientId);
+ Assert.Equal(grant.Description, capturedGrant.Description);
+ Assert.Equal(grant.CreationTime, capturedGrant.CreationTime);
+ Assert.Equal(grant.Expiration, capturedGrant.Expiration);
+ Assert.Equal(grant.ConsumedTime, capturedGrant.ConsumedTime);
+ Assert.Equal(grant.Data, capturedGrant.Data);
+ }
+
+ private static PersistedGrant CreateTestGrant(string key, DateTime? expiration)
+ {
+ return new PersistedGrant
+ {
+ Key = key,
+ Type = "authorization_code",
+ SubjectId = "test-subject",
+ ClientId = "test-client",
+ CreationTime = DateTime.UtcNow,
+ Expiration = expiration,
+ Data = "{\"test\":\"data\"}"
+ };
+ }
+}