Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions docs/content/client-sdk/session-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ Each summary provides information suitable for building a device management inte
| `LastSeenAt` | `DateTimeOffset?` | Last recorded activity |
| `RotationCount` | `int` | Recorded rotation count |
| `TouchCount` | `int` | Recorded touch count |
| `SessionCount` | `int` | Total number of session records in the chain, including historical and revoked sessions |
| `IsRevoked` | `bool` | Whether the chain is revoked |
| `RevokedAt` | `DateTimeOffset?` | Revocation timestamp |
| `ActiveSessionId` | `AuthSessionId?` | Associated active session identifier |
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ public interface ISessionStore
Task<UAuthSessionChain?> GetChainByDeviceAsync(UserKey userKey, DeviceId deviceId, CancellationToken ct = default);
Task<IReadOnlyList<UAuthSessionChain>> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default);
Task<IReadOnlyList<UAuthSession>> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default);
Task<IReadOnlyDictionary<SessionChainId, int>> GetSessionCountsByChainsAsync(IReadOnlyCollection<SessionChainId> chainIds, CancellationToken ct = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public sealed record SessionChainSummary
public DateTimeOffset? LastSeenAt { get; init; }
public int RotationCount { get; init; }
public int TouchCount { get; init; }
public int SessionCount { get; init; }
public bool IsRevoked { get; init; }
public DateTimeOffset? RevokedAt { get; init; }
public AuthSessionId? ActiveSessionId { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,10 @@ public async Task<PagedResult<SessionChainSummary>> GetUserChainsAsync(AccessCon

var total = chains.Count;

var pageItems = chains
.Skip((request.PageNumber - 1) * request.PageSize)
.Take(request.PageSize)
var pageChains = chains.Skip((request.PageNumber - 1) * request.PageSize).Take(request.PageSize).ToList();
var sessionCounts = await store.GetSessionCountsByChainsAsync(pageChains.Select(c => c.ChainId).ToArray(), innerCt);

var pageItems = pageChains
.Select(c => new SessionChainSummary
{
ChainId = c.ChainId,
Expand All @@ -94,11 +95,13 @@ public async Task<PagedResult<SessionChainSummary>> GetUserChainsAsync(AccessCon
LastSeenAt = c.LastSeenAt,
RotationCount = c.RotationCount,
TouchCount = c.TouchCount,
SessionCount = sessionCounts.GetValueOrDefault(c.ChainId),
IsRevoked = c.IsRevoked,
RevokedAt = c.RevokedAt,
ActiveSessionId = c.ActiveSessionId,
IsCurrentDevice = actorChainId.HasValue && c.ChainId == actorChainId.Value,
State = c.State,
IsCurrentDevice =
actorChainId.HasValue && c.ChainId == actorChainId.Value,
State = c.State
})
.ToList();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -867,4 +867,21 @@ public async Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Can
rootDomain.UpdateProjection(rootProjection);
rootProjection.Version++;
}

public async Task<IReadOnlyDictionary<SessionChainId, int>> GetSessionCountsByChainsAsync(IReadOnlyCollection<SessionChainId> chainIds, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();

if (chainIds.Count == 0)
return new Dictionary<SessionChainId, int>();

var ids = chainIds.Distinct().ToArray();

return await DbSetSession
.AsNoTracking()
.Where(s => s.Tenant == _tenant && ids.Contains(s.ChainId))
.GroupBy(s => s.ChainId)
.Select(g => new { ChainId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ChainId, x => x.Count, ct);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -574,4 +574,23 @@ public Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Cancellat

return Task.CompletedTask;
}

public Task<IReadOnlyDictionary<SessionChainId, int>> GetSessionCountsByChainsAsync(IReadOnlyCollection<SessionChainId> chainIds, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();

if (chainIds.Count == 0)
{
return Task.FromResult<IReadOnlyDictionary<SessionChainId, int>>(new Dictionary<SessionChainId, int>());
}

var ids = chainIds.ToHashSet();

IReadOnlyDictionary<SessionChainId, int> counts = _sessions.Values
.Where(s => ids.Contains(s.ChainId))
.GroupBy(s => s.ChainId)
.ToDictionary(g => g.Key, g => g.Count());

return Task.FromResult(counts);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,68 @@ public async Task RevokeRootAsync_RevokesRootAndItsChains()
(await store.GetChainAsync(secondChain.ChainId))!.IsRevoked.Should().BeTrue();
}

[Fact]
public async Task GetUserChainsAsync_ReturnsTotalSessionCountPerChain()
{
var (sut, store) = CreateSut();
var user = UserKey.New();
var root = await CreateRootAsync(store, user);

var firstChain = CreateChain(root, Now.AddMinutes(-30));
var secondChain = CreateChain(root, Now.AddMinutes(-20));

await store.CreateChainAsync(firstChain);
await store.CreateChainAsync(secondChain);

var firstSession = CreateSession(
user, firstChain.ChainId, Now.AddMinutes(-25), "first-1");

var rotatedSession = CreateSession(
user, firstChain.ChainId, Now.AddMinutes(-15), "first-2");

var secondSession = CreateSession(
user, secondChain.ChainId, Now.AddMinutes(-10), "second-1");

await store.CreateSessionAsync(firstSession);
await store.CreateSessionAsync(rotatedSession);
await store.CreateSessionAsync(secondSession);

var result = await sut.GetUserChainsAsync(
Context(user),
user,
new PageRequest { PageNumber = 1, PageSize = 10 });

result.Items.Single(x => x.ChainId == firstChain.ChainId)
.SessionCount.Should().Be(2);

result.Items.Single(x => x.ChainId == secondChain.ChainId)
.SessionCount.Should().Be(1);
}

[Fact]
public async Task GetUserChainsAsync_RevokedSessionsRemainCounted()
{
var (sut, store) = CreateSut();
var user = UserKey.New();
var root = await CreateRootAsync(store, user);

var chain = CreateChain(root, Now.AddMinutes(-30));
await store.CreateChainAsync(chain);

var first = CreateSession(user, chain.ChainId, Now.AddMinutes(-20), "revoked-1");

var second = CreateSession(user, chain.ChainId, Now.AddMinutes(-10), "revoked-2");

await store.CreateSessionAsync(first);
await store.CreateSessionAsync(second);

await store.RevokeChainCascadeAsync(chain.ChainId, Now);

var result = await sut.GetUserChainsAsync(Context(user), user, new PageRequest { PageNumber = 1, PageSize = 10 });

result.Items.Single(x => x.ChainId == chain.ChainId).SessionCount.Should().Be(2);
}

// ---------------------------------------------------------------------
// Infrastructure / Helpers
// ---------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@

namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts;

public sealed class InMemorySessionStoreChainContractTests
: SessionStoreChainContractTests
public sealed class InMemorySessionStoreChainContractTests : SessionStoreChainContractTests
{
protected override Task<ISessionStoreTestDatabase>
CreateDatabaseAsync()
Expand Down
Loading