diff --git a/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs b/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs deleted file mode 100644 index ecb2f36cec..0000000000 --- a/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index 3ec45377e3..0000000000 --- a/bitwarden_license/src/Sso/Utilities/PersistedGrantsDistributedCacheConstants.cs +++ /dev/null @@ -1,10 +0,0 @@ -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 da7a79535e..a51a04f5c8 100644 --- a/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs @@ -9,7 +9,6 @@ using Bit.Sso.IdentityServer; 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; @@ -78,17 +77,6 @@ public static class ServiceCollectionExtensions }) .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 deleted file mode 100644 index c0aa93f068..0000000000 --- a/bitwarden_license/test/SSO.Test/IdentityServer/DistributedCachePersistedGrantStoreTests.cs +++ /dev/null @@ -1,257 +0,0 @@ -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\"}" - }; - } -}