mirror of
https://github.com/bitwarden/server
synced 2026-01-15 15:03:34 +00:00
feat(single sign-on) [PM-23572] Add Persistent Grants to SSO (#6636)
* feat(sso-persisted-grants) [PM-23572]: Stub PersistedGrantStore. * feat(sso-persisted-grants) [PM-23572]: Update service reigtration with named cache. * feat(sso-persisted-grants) [PM-23572]: Add unit tests for DistributedCachePersistedGrantStore. * feat(sso-persisted-grants) [PM-23572]: Add additional tests. * feat(sso-persisted-grants) [PM-23572]: Add some additional clarifying comments on ExtendedCache vs InMemoryCaching for Duende. * feat(sso-persistent-grants) [PM-23572]: Spelling in a comment for cache key name. * feat(sso-persisted-grants) [PM-23572]: Add cache key constant and remove explicit skip distributed cache on set for default configuration. --------- Co-authored-by: bnagawiecki <107435978+bnagawiecki@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
using Bit.Sso.Utilities;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using ZiggyCreatures.Caching.Fusion;
|
||||
|
||||
namespace Bit.Sso.IdentityServer;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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).
|
||||
/// <seealso href="https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/"/>
|
||||
/// </remarks>
|
||||
public class DistributedCachePersistedGrantStore : IPersistedGrantStore
|
||||
{
|
||||
private readonly IFusionCache _cache;
|
||||
|
||||
public DistributedCachePersistedGrantStore(
|
||||
[FromKeyedServices(PersistedGrantsDistributedCacheConstants.CacheKey)] 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 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<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
|
||||
: 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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public static class PersistedGrantsDistributedCacheConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const string CacheKey = "sso-grants";
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
|
||||
@@ -77,6 +78,17 @@ 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<IPersistedGrantStore, DistributedCachePersistedGrantStore>();
|
||||
|
||||
return identityServerBuilder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 GetAsync_WithValidGrant_ReturnsGrant()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("valid-key", expiration: DateTime.UtcNow.AddMinutes(5));
|
||||
_cache.TryGetAsync<PersistedGrant>("valid-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.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<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithNonExistentKey_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
_cache.TryGetAsync<PersistedGrant>("nonexistent-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.None);
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAsync("nonexistent-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithExpiredGrant_RemovesAndReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
|
||||
_cache.TryGetAsync<PersistedGrant>("expired-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.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<PersistedGrant>("no-expiry-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.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<string>());
|
||||
}
|
||||
|
||||
[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\"}"
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user