Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

[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

Open
enmande wants to merge6 commits intomain
base:main
Choose a base branch
Loading
fromauth/pm-23572/support-persistent-grants-for-sso
Open
Show file tree
Hide file tree
Changes from1 commit
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
PrevPrevious commit
NextNext commit
feat(sso-persisted-grants) [PM-23572]: Add unit tests for Distributed…
…CachePersistedGrantStore.
  • Loading branch information
@enmande
enmande committedNov 21, 2025
commit03c09a3303847a8fcbbdf02cdfde480008f29644

Some comments aren't visible on the classic Files Changed page.

View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,83 +21,62 @@ namespace Bit.Sso.IdentityServer;
public class DistributedCachePersistedGrantStore : IPersistedGrantStore
{
private readonly IFusionCache _cache;
private readonly ILogger<DistributedCachePersistedGrantStore> _logger;

private const string KeyPrefix = "grant:";

public DistributedCachePersistedGrantStore(
[FromKeyedServices("sso-grants")] IFusionCache cache,
ILogger<DistributedCachePersistedGrantStore> logger)
[FromKeyedServices("sso-grants")] IFusionCache cache)
Copy link
Contributor

Choose a reason for hiding this comment

The 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;
_logger = logger;
}

public async Task<PersistedGrant?> GetAsync(string key)
{
var cacheKey = GetCacheKey(key);

var result = await _cache.TryGetAsync<PersistedGrant>(cacheKey);
var result = await _cache.TryGetAsync<PersistedGrant>(key);

if (!result.HasValue)
{
_logger.LogDebug("Grant {Key} not found in cache", key);
return null;
}

var grant = result.Value;

// Check expiration
if (grant.Expiration.HasValue && grant.Expiration.Value < DateTime.UtcNow)
{
_logger.LogDebug("Grant {Key} has expired", key);
await RemoveAsync(key);
return null;
}
if (!grant.Expiration.HasValue || grant.Expiration.Value >= DateTime.UtcNow) return grant;
await RemoveAsync(key);
return null;

_logger.LogDebug("Retrieved grant {Key} of type {GrantType}", key, grant.Type);
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.
_logger.LogDebug(
"GetAllAsync called on cache-backed store with filter SubjectId={SubjectId}, SessionId={SessionId}, ClientId={ClientId}, Type={Type}. " +
"Cache stores do not support filtering. Returning empty collection.",
filter.SubjectId, filter.SessionId, filter.ClientId, filter.Type);

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.
_logger.LogDebug(
"RemoveAllAsync called on cache-backed store with filter SubjectId={SubjectId}, SessionId={SessionId}, ClientId={ClientId}, Type={Type}. " +
"Cache stores do not support filtering. No action taken.",
filter.SubjectId, filter.SessionId, filter.ClientId, filter.Type);

return Task.FromResult(0);
}

public async Task RemoveAsync(string key)
{
var cacheKey = GetCacheKey(key);

await _cache.RemoveAsync(cacheKey);

_logger.LogDebug("Removed grant {Key} from cache", key);
await _cache.RemoveAsync(key);
}

public async Task StoreAsync(PersistedGrant grant)
{
var cacheKey = GetCacheKey(grant.Key);

// Calculate TTL based on grant expiration
var duration = grant.Expiration.HasValue
? grant.Expiration.Value - DateTime.UtcNow
Expand All@@ -106,23 +85,19 @@ public async Task StoreAsync(PersistedGrant grant)
// Ensure positive duration
if (duration <= TimeSpan.Zero)
{
_logger.LogWarning("Grant {Key} has already expired. Not storing in cache.", grant.Key);
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(
cacheKey,
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));

_logger.LogDebug("Stored grant {Key} of type {GrantType} with TTL {Duration}s",
grant.Key, grant.Type, duration.TotalSeconds);
}

private string GetCacheKey(string key) => $"{KeyPrefix}{key}";
}
View file
Open in desktop
Original file line numberDiff line numberDiff 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\"}"
};
}
}

[8]ページ先頭

©2009-2025 Movatter.jp