- Notifications
You must be signed in to change notification settings - Fork1.5k
[PM-23572] Add Persistent Grants to SSO#6636
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
base:main
Are you sure you want to change the base?
Changes from1 commit
81fe0e3dbfa1f003c09a36b9558827b129d04d1a16File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
…CachePersistedGrantStore.
- Loading branch information
Uh oh!
There was an error while loading.Please reload this page.
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -21,83 +21,62 @@ namespace Bit.Sso.IdentityServer; | ||
| public class DistributedCachePersistedGrantStore : IPersistedGrantStore | ||
| { | ||
| private readonly IFusionCache _cache; | ||
| public DistributedCachePersistedGrantStore( | ||
| [FromKeyedServices("sso-grants")] IFusionCache cache) | ||
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. One of the patterns I was using (you can see an example in#6650) was to have a cache constants class. The virtue being that the cache name and keys are not strings but are typed to constants. It looks like you already have a consistent way of building keys, so you probably don't need that part. But it still might be worthwhile to make absolutely sure the cache name is consistent. i.e. // In constants classpublicconststringCacheName="sso-grants";// In this code[FromKeyedServices(SsoGrantsCacheConstants.CacheName)]IFusionCache cache) | ||
| { | ||
| _cache = cache; | ||
| } | ||
| public async Task<PersistedGrant?> GetAsync(string key) | ||
| { | ||
| var result = await _cache.TryGetAsync<PersistedGrant>(key); | ||
| if (!result.HasValue) | ||
| { | ||
| return null; | ||
| } | ||
| var grant = result.Value; | ||
| // Check expiration | ||
| if (!grant.Expiration.HasValue || grant.Expiration.Value >= DateTime.UtcNow) return grant; | ||
| await RemoveAsync(key); | ||
| return null; | ||
| } | ||
| public Task<IEnumerable<PersistedGrant>> 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<PersistedGrant>()); | ||
| } | ||
| 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 | ||
| @@ -106,23 +85,19 @@ public async Task StoreAsync(PersistedGrant grant) | ||
| // Ensure positive duration | ||
| if (duration <= TimeSpan.Zero) | ||
| { | ||
| return; | ||
| } | ||
| // Cache key "sso-grant:" 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, | ||
| // Keep distributed cache enabled for multi-instance scenarios | ||
| // When Redis isn't configured, FusionCache gracefully uses only L1 (in-memory) | ||
| }.SetSkipDistributedCache(false, false)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| 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<IFusionCache>(); | ||
| _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<FusionCacheEntryOptions>(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<FusionCacheEntryOptions>(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<string>(), | ||
| Arg.Any<PersistedGrant>(), | ||
| Arg.Any<FusionCacheEntryOptions?>()); | ||
| } | ||
| [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<FusionCacheEntryOptions>(opts => | ||
| opts.SkipDistributedCache == false && | ||
| opts.SkipDistributedCacheReadWhenStale == false)); | ||
| } | ||
| [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<string>()); | ||
| } | ||
| [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<string>(), | ||
| Arg.Do<PersistedGrant>(g => capturedGrant = g), | ||
| Arg.Any<FusionCacheEntryOptions?>()); | ||
| // 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\"}" | ||
| }; | ||
| } | ||
| } |