From b11fd1ea9abcf0569baa6ebd8f4b66bdadaf3127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 28 Dec 2025 23:58:07 +0300 Subject: [PATCH 1/5] Preparation of First Release v 0.0.1 (Part 2) --- UltimateAuth.slnx | 1 + .../AssemblyVisibility.cs | 3 + .../Domain/Session/AuthSessionId.cs | 2 + .../Domain/Session/ChainId.cs | 2 + .../Domain/Session/DeviceInfo.cs | 13 +++ .../Domain/Session/ISessionChain.cs | 2 + .../Domain/Session/UAuthSession.cs | 32 ++++++ .../Domain/Session/UAuthSessionChain.cs | 24 ++++ .../Domain/Session/UAuthSessionRoot.cs | 21 ++++ ...teAuth.Sessions.EntityFrameworkCore.csproj | 28 +++++ .../EfCoreSessionStoreKernel.cs | 47 ++++++++ .../SessionChainProjection.cs | 26 +++++ .../EntityProjections/SessionProjection.cs | 31 ++++++ .../SessionRootProjection.cs | 18 +++ .../JsonValueConverter.cs | 15 +++ .../Mappers/SessionChainProjectionMapper.cs | 42 +++++++ .../Mappers/SessionProjectionMapper.cs | 54 +++++++++ .../Mappers/SessionRootProjectionMapper.cs | 36 ++++++ .../NullableAuthSessionIdConverter.cs | 15 +++ .../UAuthSessionDbContext.cs | 104 ++++++++++++++++++ 20 files changed, 516 insertions(+) create mode 100644 src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/JsonValueConverter.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 8767d52..7343d62 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -16,6 +16,7 @@ + diff --git a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs new file mode 100644 index 0000000..07826e2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore")] diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs index 121f389..e6261ca 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs @@ -32,6 +32,8 @@ public static bool TryCreate(string raw, out AuthSessionId sessionId) /// public string Value { get; } + public static AuthSessionId From(string value) => new(value); + /// /// Determines whether the specified is equal to the current instance. /// diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs index 292898b..486c80c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs @@ -27,6 +27,8 @@ public ChainId(Guid value) /// A new instance. public static ChainId New() => new ChainId(Guid.NewGuid()); + public static ChainId From(Guid value) => new(value); + /// /// Determines whether the specified is equal to the current instance. /// diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs index c70c7e5..969b474 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs @@ -7,8 +7,10 @@ /// public sealed class DeviceInfo { + // TODO: Implement DeviceId and makes it first-class citizen in security policies. /// /// Gets the unique identifier for the device. + /// No session should be created without a device id. /// public string DeviceId { get; init; } = default!; @@ -72,6 +74,17 @@ public sealed class DeviceInfo IsTrusted = null }; + // TODO: Empty may not be good approach, make strict security here + public static DeviceInfo Empty { get; } = new() + { + DeviceId = "", + Platform = null, + Browser = null, + IpAddress = null, + UserAgent = null, + IsTrusted = null + }; + /// /// Determines whether the current device information matches the specified device information based on device /// identifiers. diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs index da2b8d8..c7faf4a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs @@ -12,6 +12,8 @@ public interface ISessionChain /// ChainId ChainId { get; } + string? TenantId { get; } + /// /// Gets the identifier of the user who owns this chain. /// Each chain represents one device/login family for this user. diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index 1de4b27..154aeaf 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -136,6 +136,38 @@ public ISession Revoke(DateTimeOffset at) ); } + internal static UAuthSession FromProjection( + AuthSessionId sessionId, + string? tenantId, + TUserId userId, + ChainId chainId, + DateTimeOffset createdAt, + DateTimeOffset expiresAt, + DateTimeOffset? lastSeenAt, + bool isRevoked, + DateTimeOffset? revokedAt, + long securityVersionAtCreation, + DeviceInfo device, + ClaimsSnapshot claims, + SessionMetadata metadata) + { + return new UAuthSession( + sessionId, + tenantId, + userId, + chainId, + createdAt, + expiresAt, + lastSeenAt, + isRevoked, + revokedAt, + securityVersionAtCreation, + device, + claims, + metadata + ); + } + public SessionState GetState(DateTimeOffset at) { if (IsRevoked) return SessionState.Revoked; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index 70849ea..91ecbce 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -108,5 +108,29 @@ public ISessionChain Revoke(DateTimeOffset at) ); } + internal static UAuthSessionChain FromProjection( + ChainId chainId, + string? tenantId, + TUserId userId, + int rotationCount, + long securityVersionAtCreation, + ClaimsSnapshot claimsSnapshot, + AuthSessionId? activeSessionId, + bool isRevoked, + DateTimeOffset? revokedAt) + { + return new UAuthSessionChain( + chainId, + tenantId, + userId, + rotationCount, + securityVersionAtCreation, + claimsSnapshot, + activeSessionId, + isRevoked, + revokedAt + ); + } + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs index 1f6641f..ae41b27 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -76,5 +76,26 @@ public ISessionRoot AttachChain(ISessionChain chain, DateTimeO ); } + internal static UAuthSessionRoot FromProjection( + string? tenantId, + TUserId userId, + bool isRevoked, + DateTimeOffset? revokedAt, + long securityVersion, + IReadOnlyList> chains, + DateTimeOffset lastUpdatedAt) + { + return new UAuthSessionRoot( + tenantId, + userId, + isRevoked, + revokedAt, + securityVersion, + chains, + lastUpdatedAt + ); + } + + } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj new file mode 100644 index 0000000..e656299 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + + + + + + + + diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs new file mode 100644 index 0000000..5982b75 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStoreKernel.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using System.Data; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal sealed class EfCoreSessionStoreKernel + { + private readonly UltimateAuthSessionDbContext _db; + + public EfCoreSessionStoreKernel(UltimateAuthSessionDbContext db) + { + _db = db; + } + + public async Task ExecuteAsync(Func action, CancellationToken ct) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + await strategy.ExecuteAsync(async () => + { + var connection = _db.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + await connection.OpenAsync(ct); + + await using var tx = await connection.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + _db.Database.UseTransaction(tx); + + try + { + await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + finally + { + _db.Database.UseTransaction(null); + } + }); + } + + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs new file mode 100644 index 0000000..fac4658 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal sealed class SessionChainProjection + { + public long Id { get; set; } + + public ChainId ChainId { get; set; } = default!; + + public string? TenantId { get; set; } + public TUserId UserId { get; set; } = default!; + + public int RotationCount { get; set; } + public long SecurityVersionAtCreation { get; set; } + + public ClaimsSnapshot ClaimsSnapshot { get; set; } = ClaimsSnapshot.Empty; + + public AuthSessionId? ActiveSessionId { get; set; } + + public bool IsRevoked { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + + public byte[] RowVersion { get; set; } = default!; + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs new file mode 100644 index 0000000..6698c41 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs @@ -0,0 +1,31 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal sealed class SessionProjection + { + public long Id { get; set; } // EF internal PK + + public AuthSessionId SessionId { get; set; } = default!; + public ChainId ChainId { get; set; } = default!; + + public string? TenantId { get; set; } + public TUserId UserId { get; set; } = default!; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? LastSeenAt { get; set; } + + public bool IsRevoked { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + + public long SecurityVersionAtCreation { get; set; } + + public DeviceInfo Device { get; set; } = DeviceInfo.Empty; + public ClaimsSnapshot Claims { get; set; } = ClaimsSnapshot.Empty; + public SessionMetadata Metadata { get; set; } = SessionMetadata.Empty; + + public byte[] RowVersion { get; set; } = default!; + } + +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs new file mode 100644 index 0000000..bc4dc81 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs @@ -0,0 +1,18 @@ +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal sealed class SessionRootProjection + { + public long Id { get; set; } + + public string? TenantId { get; set; } + public TUserId UserId { get; set; } = default!; + + public bool IsRevoked { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + + public long SecurityVersion { get; set; } + public DateTimeOffset LastUpdatedAt { get; set; } + + public byte[] RowVersion { get; set; } = default!; + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/JsonValueConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/JsonValueConverter.cs new file mode 100644 index 0000000..0d5b86d --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/JsonValueConverter.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal sealed class JsonValueConverter : ValueConverter + { + public JsonValueConverter() + : base( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => JsonSerializer.Deserialize(v, (JsonSerializerOptions?)null)!) + { + } + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs new file mode 100644 index 0000000..62ab304 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -0,0 +1,42 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal static class SessionChainProjectionMapper + { + public static ISessionChain ToDomain(this SessionChainProjection p) + { + return UAuthSessionChain.FromProjection( + p.ChainId, + p.TenantId, + p.UserId, + p.RotationCount, + p.SecurityVersionAtCreation, + p.ClaimsSnapshot, + p.ActiveSessionId, + p.IsRevoked, + p.RevokedAt + ); + } + + public static SessionChainProjection ToProjection(this ISessionChain chain) + { + return new SessionChainProjection + { + ChainId = chain.ChainId, + TenantId = chain.TenantId, + UserId = chain.UserId, + + RotationCount = chain.RotationCount, + SecurityVersionAtCreation = chain.SecurityVersionAtCreation, + ClaimsSnapshot = chain.ClaimsSnapshot, + + ActiveSessionId = chain.ActiveSessionId, + + IsRevoked = chain.IsRevoked, + RevokedAt = chain.RevokedAt + }; + } + + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs new file mode 100644 index 0000000..37cc755 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -0,0 +1,54 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal static class SessionProjectionMapper + { + public static ISession ToDomain(this SessionProjection p) + { + var device = p.Device == DeviceInfo.Empty + ? DeviceInfo.Unknown + : p.Device; + + return UAuthSession.FromProjection( + p.SessionId, + p.TenantId, + p.UserId, + p.ChainId, + p.CreatedAt, + p.ExpiresAt, + p.LastSeenAt, + p.IsRevoked, + p.RevokedAt, + p.SecurityVersionAtCreation, + device, + p.Claims, + p.Metadata + ); + } + + public static SessionProjection ToProjection(this ISession s) + { + return new SessionProjection + { + SessionId = s.SessionId, + TenantId = s.TenantId, + UserId = s.UserId, + ChainId = s.ChainId, + + CreatedAt = s.CreatedAt, + ExpiresAt = s.ExpiresAt, + LastSeenAt = s.LastSeenAt, + + IsRevoked = s.IsRevoked, + RevokedAt = s.RevokedAt, + + SecurityVersionAtCreation = s.SecurityVersionAtCreation, + Device = s.Device, + Claims = s.Claims, + Metadata = s.Metadata + }; + } + + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs new file mode 100644 index 0000000..d722485 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs @@ -0,0 +1,36 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal static class SessionRootProjectionMapper + { + public static ISessionRoot ToDomain(this SessionRootProjection root, IReadOnlyList> chains) + { + return UAuthSessionRoot.FromProjection( + root.TenantId, + root.UserId, + root.IsRevoked, + root.RevokedAt, + root.SecurityVersion, + chains, + root.LastUpdatedAt + ); + } + + public static SessionRootProjection ToProjection(this ISessionRoot root) + { + return new SessionRootProjection + { + TenantId = root.TenantId, + UserId = root.UserId, + + IsRevoked = root.IsRevoked, + RevokedAt = root.RevokedAt, + + SecurityVersion = root.SecurityVersion, + LastUpdatedAt = root.LastUpdatedAt + }; + } + + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs new file mode 100644 index 0000000..3453058 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/NullableAuthSessionIdConverter.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal sealed class NullableAuthSessionIdConverter : ValueConverter + { + public NullableAuthSessionIdConverter() + : base( + v => v == null ? null : v.Value, + v => v == null ? null : AuthSessionId.From(v)) + { + } + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs new file mode 100644 index 0000000..fa94bd6 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/UAuthSessionDbContext.cs @@ -0,0 +1,104 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +{ + internal sealed class UltimateAuthSessionDbContext : DbContext + { + public DbSet> Roots => Set>(); + public DbSet> Chains => Set>(); + public DbSet> Sessions => Set>(); + + public UltimateAuthSessionDbContext(DbContextOptions options) : base(options) + { + + } + + protected override void OnModelCreating(ModelBuilder b) + { + b.Entity>(e => + { + e.HasKey(x => x.Id); + + e.Property(x => x.RowVersion) + .IsRowVersion(); + + e.Property(x => x.UserId) + .IsRequired(); + + e.HasIndex(x => new { x.TenantId, x.UserId }) + .IsUnique(); + + e.Property(x => x.SecurityVersion) + .IsRequired(); + + e.Property(x => x.LastUpdatedAt) + .IsRequired(); + }); + + b.Entity>(e => + { + e.HasKey(x => x.Id); + + e.Property(x => x.RowVersion) + .IsRowVersion(); + + e.Property(x => x.UserId) + .IsRequired(); + + e.HasIndex(x => x.ChainId) + .IsUnique(); + + e.Property(x => x.ChainId) + .HasConversion( + v => v.Value, + v => ChainId.From(v)) + .IsRequired(); + + e.Property(x => x.ActiveSessionId) + .HasConversion(new NullableAuthSessionIdConverter()); + + e.Property(x => x.ClaimsSnapshot) + .HasConversion(new JsonValueConverter()) + .IsRequired(); + + e.Property(x => x.SecurityVersionAtCreation) + .IsRequired(); + }); + + b.Entity>(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.RowVersion).IsRowVersion(); + + e.HasIndex(x => x.SessionId).IsUnique(); + e.HasIndex(x => new { x.ChainId, x.RevokedAt }); + + e.Property(x => x.SessionId) + .HasConversion( + v => v.Value, + v => AuthSessionId.From(v)) + .IsRequired(); + + e.Property(x => x.ChainId) + .HasConversion( + v => v.Value, + v => ChainId.From(v)) + .IsRequired(); + + e.Property(x => x.Device) + .HasConversion(new JsonValueConverter()) + .IsRequired(); + + e.Property(x => x.Claims) + .HasConversion(new JsonValueConverter()) + .IsRequired(); + + e.Property(x => x.Metadata) + .HasConversion(new JsonValueConverter()) + .IsRequired(); + }); + } + + } +} From 12e1caef06f6ca7b229cc89b46934961d53e76d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 29 Dec 2025 00:01:57 +0300 Subject: [PATCH 2/5] Add Project References To Test Project --- .../CodeBeam.UltimateAuth.Core.Tests.csproj | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj b/tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj index 88d7cd4..2653370 100644 --- a/tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj +++ b/tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj @@ -16,7 +16,15 @@ + + + + + + + + From 1018aec4e785e52fea2c1353ef585f1c34b5edc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 29 Dec 2025 22:34:38 +0300 Subject: [PATCH 3/5] Add CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore project & Implementation --- .../Abstractions/Stores/ISessionStore.cs | 28 +- .../EfCoreSessionActivityWriter.cs | 33 ++ .../EfCoreSessionStore.cs | 360 ++++++++++++++++++ .../ServiceCollectionExtensions.cs | 18 + .../InMemorySessionStore.cs | 28 +- 5 files changed, 423 insertions(+), 44 deletions(-) create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs create mode 100644 src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index 8126aca..902b905 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -12,47 +12,31 @@ public interface ISessionStore /// /// Retrieves an active session by id. /// - Task?> GetSessionAsync( - string? tenantId, - AuthSessionId sessionId); + Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default); /// /// Creates a new session and associates it with the appropriate chain and root. /// - Task CreateSessionAsync( - IssuedSession issuedSession, - SessionStoreContext context); + Task CreateSessionAsync(IssuedSession issuedSession, SessionStoreContext context, CancellationToken ct = default); /// /// Refreshes (rotates) the active session within its chain. /// - Task RotateSessionAsync( - AuthSessionId currentSessionId, - IssuedSession newSession, - SessionStoreContext context); + Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession newSession, SessionStoreContext context, CancellationToken ct = default); /// /// Revokes a single session. /// - Task RevokeSessionAsync( - string? tenantId, - AuthSessionId sessionId, - DateTimeOffset at); + Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default); /// /// Revokes all sessions for a specific user (all devices). /// - Task RevokeAllSessionsAsync( - string? tenantId, - TUserId userId, - DateTimeOffset at); + Task RevokeAllSessionsAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default); /// /// Revokes all sessions within a specific chain (single device). /// - Task RevokeChainAsync( - string? tenantId, - ChainId chainId, - DateTimeOffset at); + Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken ct = default); } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs new file mode 100644 index 0000000..c998acd --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionActivityWriter.cs @@ -0,0 +1,33 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal sealed class EfCoreSessionActivityWriter : ISessionActivityWriter where TUserId : notnull +{ + private readonly UltimateAuthSessionDbContext _db; + + public EfCoreSessionActivityWriter(UltimateAuthSessionDbContext db) + { + _db = db; + } + + public async Task TouchAsync(string? tenantId, ISession session, CancellationToken ct) + { + var projection = await _db.Sessions + .SingleOrDefaultAsync( + x => x.SessionId == session.SessionId && + x.TenantId == tenantId, + ct); + + if (projection is null) + return; + // TODO: Rethink architecture + var updated = session as UAuthSession + ?? throw new InvalidOperationException("EF Core ActivityWriter requires UAuthSession instance."); + + _db.Sessions.Update(updated.ToProjection()); + await _db.SaveChangesAsync(ct); + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs new file mode 100644 index 0000000..4a69c9c --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EfCoreSessionStore.cs @@ -0,0 +1,360 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore; +using System.Security; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal sealed class EfCoreSessionStore : ISessionStore +{ + private readonly EfCoreSessionStoreKernel _kernel; + private readonly UltimateAuthSessionDbContext _db; + + public EfCoreSessionStore(EfCoreSessionStoreKernel kernel, UltimateAuthSessionDbContext db) + { + _kernel = kernel; + _db = db; + } + + public async Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) + { + var projection = await _db.Sessions + .AsNoTracking() + .Where(x => + x.SessionId == sessionId && + x.TenantId == tenantId) + .SingleOrDefaultAsync(ct); + + if (projection is null) + return null; + + return projection.ToDomain(); + } + + public async Task CreateSessionAsync(IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) + { + await _kernel.ExecuteAsync(async ct => + { + var now = ctx.IssuedAt; + + var rootProjection = await _db.Roots.SingleOrDefaultAsync(x => x.TenantId == ctx.TenantId && x.UserId!.Equals(ctx.UserId), ct); + + ISessionRoot root; + + if (rootProjection is null) + { + root = UAuthSessionRoot.Create(ctx.TenantId, ctx.UserId, now); + _db.Roots.Add(root.ToProjection()); + } + else + { + var chains = await LoadChainsAsync(ctx, ct); + root = rootProjection.ToDomain(chains); + } + + + ISessionChain chain; + + if (ctx.ChainId is not null) + { + var chainProjection = await _db.Chains.SingleAsync(x => x.ChainId == ctx.ChainId.Value, ct); + chain = chainProjection.ToDomain(); + } + else + { + chain = UAuthSessionChain.Create( + ChainId.New(), + ctx.TenantId, + ctx.UserId, + root.SecurityVersion, + ClaimsSnapshot.Empty); + + _db.Chains.Add(chain.ToProjection()); + root = root.AttachChain(chain, now); + } + + var session = UAuthSession.Create( + issued.Session.SessionId, + ctx.TenantId, + ctx.UserId, + chain.ChainId, + now, + issued.Session.ExpiresAt, + ctx.DeviceInfo, + issued.Session.Claims, + metadata: SessionMetadata.Empty + ); + + _db.Sessions.Add(session.ToProjection()); + var updatedChain = chain.AttachSession(session.SessionId); + _db.Chains.Update(updatedChain.ToProjection()); + + }, ct); + } + + public async Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) + { + await _kernel.ExecuteAsync(async ct => + { + var now = ctx.IssuedAt; + + var oldSessionProjection = await _db.Sessions.SingleOrDefaultAsync( + x => x.SessionId == currentSessionId && + x.TenantId == ctx.TenantId, + ct); + + if (oldSessionProjection is null) + throw new SecurityException("Session not found."); + + var oldSession = oldSessionProjection.ToDomain(); + + var chainProjection = await _db.Chains.SingleOrDefaultAsync( + x => x.ChainId == oldSession.ChainId, ct); + + if (chainProjection is null) + throw new SecurityException("Chain not found."); + + var chain = chainProjection.ToDomain(); + + if (chain.IsRevoked) + throw new SecurityException("Session chain is revoked."); + + var newSession = UAuthSession.Create( + issued.Session.SessionId, + ctx.TenantId, + ctx.UserId, + chain.ChainId, + now, + issued.Session.ExpiresAt, + ctx.DeviceInfo, + issued.Session.Claims, + metadata: SessionMetadata.Empty + ); + + _db.Sessions.Add(newSession.ToProjection()); + + var updatedChain = chain.RotateSession(newSession.SessionId); + _db.Chains.Update(updatedChain.ToProjection()); + + var revokedOldSession = oldSession.Revoke(now); + _db.Sessions.Update(revokedOldSession.ToProjection()); + + }, ct); + } + + public async Task RevokeAllSessionsAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default) + { + await _kernel.ExecuteAsync(async ct => + { + var rootProjection = await _db.Roots + .SingleOrDefaultAsync( + x => x.TenantId == tenantId && + x.UserId!.Equals(userId), + ct); + + if (rootProjection is null) + return; + + var chainProjections = await _db.Chains + .Where(x => + x.TenantId == tenantId && + x.UserId!.Equals(userId)) + .ToListAsync(ct); + + foreach (var chainProjection in chainProjections) + { + var chain = chainProjection.ToDomain(); + + if (chain.IsRevoked) + continue; + + var revokedChain = chain.Revoke(at); + _db.Chains.Update(revokedChain.ToProjection()); + + if (chain.ActiveSessionId is not null) + { + var sessionProjection = await _db.Sessions + .SingleOrDefaultAsync( + x => x.SessionId == chain.ActiveSessionId && + x.TenantId == tenantId, + ct); + + if (sessionProjection is not null) + { + var session = sessionProjection.ToDomain(); + var revokedSession = session.Revoke(at); + _db.Sessions.Update(revokedSession.ToProjection()); + } + } + } + + var root = rootProjection.ToDomain(chainProjections + .Select(c => c.ToDomain()) + .ToList()); + + var revokedRoot = root.Revoke(at); + _db.Roots.Update(revokedRoot.ToProjection()); + + }, ct); + } + + public async Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + await _kernel.ExecuteAsync(async ct => + { + var chainProjection = await _db.Chains + .SingleOrDefaultAsync( + x => x.ChainId == chainId && + x.TenantId == tenantId, + ct); + + if (chainProjection is null) + return; + + var chain = chainProjection.ToDomain(); + + if (chain.IsRevoked) + return; + + var revokedChain = chain.Revoke(at); + _db.Chains.Update(revokedChain.ToProjection()); + + if (chain.ActiveSessionId is not null) + { + var sessionProjection = await _db.Sessions + .SingleOrDefaultAsync( + x => x.SessionId == chain.ActiveSessionId && + x.TenantId == tenantId, + ct); + + if (sessionProjection is not null) + { + var session = sessionProjection.ToDomain(); + var revokedSession = session.Revoke(at); + _db.Sessions.Update(revokedSession.ToProjection()); + } + } + + }, ct); + } + + public async Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + { + await _kernel.ExecuteAsync(async ct => + { + var sessionProjection = await _db.Sessions + .SingleOrDefaultAsync( + x => x.SessionId == sessionId && + x.TenantId == tenantId, + ct); + + if (sessionProjection is null) + return; + + var session = sessionProjection.ToDomain(); + + if (session.IsRevoked) + return; + + var revokedSession = session.Revoke(at); + _db.Sessions.Update(revokedSession.ToProjection()); + + }, ct); + } + + public async Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) + { + var projections = await _db.Sessions + .AsNoTracking() + .Where(x => + x.ChainId == chainId && + x.TenantId == tenantId) + .ToListAsync(ct); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task>> GetChainsByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + var projections = await _db.Chains + .AsNoTracking() + .Where(x => + x.TenantId == tenantId && + x.UserId!.Equals(userId)) + .ToListAsync(ct); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task?> GetChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) + { + var projection = await _db.Chains + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.ChainId == chainId && + x.TenantId == tenantId, + ct); + + return projection?.ToDomain(); + } + + public async Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) + { + return await _db.Sessions + .AsNoTracking() + .Where(x => + x.SessionId == sessionId && + x.TenantId == tenantId) + .Select(x => (ChainId?)x.ChainId) + .SingleOrDefaultAsync(ct); + } + + public async Task GetActiveSessionIdAsync(string? tenantId, ChainId chainId, CancellationToken ct = default) + { + return await _db.Chains + .AsNoTracking() + .Where(x => + x.ChainId == chainId && + x.TenantId == tenantId) + .Select(x => x.ActiveSessionId) + .SingleOrDefaultAsync(ct); + } + + public async Task?> GetSessionRootAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + var rootProjection = await _db.Roots + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.TenantId == tenantId && + x.UserId!.Equals(userId), + ct); + + if (rootProjection is null) + return null; + + var chainProjections = await _db.Chains + .AsNoTracking() + .Where(x => + x.TenantId == tenantId && + x.UserId!.Equals(userId)) + .ToListAsync(ct); + + return rootProjection.ToDomain(chainProjections.Select(x => x.ToDomain()).ToList()); + } + + + private async Task>> LoadChainsAsync(SessionStoreContext ctx, CancellationToken ct) + { + var chainProjections = await _db.Chains + .AsNoTracking() + .Where(x => + x.TenantId == ctx.TenantId && + x.UserId!.Equals(ctx.UserId)) + .ToListAsync(ct); + + return chainProjections + .Select(x => x.ToDomain()) + .ToList(); + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..54b9dbd --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthEntityFrameworkCoreSessions(this IServiceCollection services,Action configureDb)where TUserId : notnull + { + services.AddDbContext>(configureDb); + services.AddScoped>(); + services.AddScoped, EfCoreSessionStore>(); + services.AddScoped, EfCoreSessionActivityWriter>(); + + return services; + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs index 24e1e56..b00b646 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -17,14 +17,10 @@ public InMemorySessionStore(ISessionStoreFactory factory) private ISessionStoreKernel Kernel(string? tenantId) => _factory.Create(tenantId); - public Task?> GetSessionAsync( - string? tenantId, - AuthSessionId sessionId) + public Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default) => Kernel(tenantId).GetSessionAsync(tenantId, sessionId); - public async Task CreateSessionAsync( - IssuedSession issued, - SessionStoreContext ctx) + public async Task CreateSessionAsync(IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) { var k = Kernel(ctx.TenantId); @@ -82,10 +78,7 @@ await k.SetActiveSessionIdAsync( }); } - public async Task RotateSessionAsync( - AuthSessionId currentSessionId, - IssuedSession issued, - SessionStoreContext ctx) + public async Task RotateSessionAsync(AuthSessionId currentSessionId, IssuedSession issued, SessionStoreContext ctx, CancellationToken ct = default) { var k = Kernel(ctx.TenantId); @@ -123,16 +116,10 @@ await k.RevokeSessionAsync( }); } - public Task RevokeSessionAsync( - string? tenantId, - AuthSessionId sessionId, - DateTimeOffset at) + public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) => Kernel(tenantId).RevokeSessionAsync(tenantId, sessionId, at); - public async Task RevokeAllSessionsAsync( - string? tenantId, - TUserId userId, - DateTimeOffset at) + public async Task RevokeAllSessionsAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default) { var k = Kernel(tenantId); @@ -159,10 +146,7 @@ await k.RevokeSessionAsync( }); } - public async Task RevokeChainAsync( - string? tenantId, - ChainId chainId, - DateTimeOffset at) + public async Task RevokeChainAsync(string? tenantId,ChainId chainId, DateTimeOffset at, CancellationToken ct = default) { var k = Kernel(tenantId); From f4716408d1e135624ecd5e82f61d580bb8c1a899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 29 Dec 2025 23:54:57 +0300 Subject: [PATCH 4/5] Add CodeBeam.UltimateAuth.Tokens.EntityFramework project & Implementation & First Unit Tests --- UltimateAuth.slnx | 3 +- .../Contracts/Token/RefreshToken.cs | 3 +- .../Domain/Token/SessionRefreshStatus.cs | 2 +- ...mateAuth.Tokens.EntityFrameworkCore.csproj | 28 +++ .../EfCoreTokenStore.cs | 174 ++++++++++++++++++ .../EfCoreTokenStoreKernel.cs | 69 +++++++ .../Projections/RefreshTokenProjection.cs | 20 ++ .../Projections/RevokedIdTokenProjection.cs | 14 ++ .../ServiceCollectionExtensions.cs | 17 ++ .../UAuthTokenDbContext.cs | 71 +++++++ ...deBeam.UltimateAuth.Tokens.InMemory.csproj | 2 +- .../InMemoryTokenStore.cs | 40 +--- .../UnitTest1.cs | 11 -- .../CodeBeam.UltimateAuth.Tests.Unit.csproj} | 1 + .../Core/AuthSessionIdTests.cs | 22 +++ .../Core/UAuthSessionChainTests.cs | 40 ++++ .../Core/UAuthSessionTests.cs | 52 ++++++ 17 files changed, 520 insertions(+), 49 deletions(-) create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStoreKernel.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs delete mode 100644 tests/CodeBeam.UltimateAuth.Core.Tests/UnitTest1.cs rename tests/{CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj => CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj} (92%) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 7343d62..5c95542 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -8,7 +8,7 @@ - + @@ -18,5 +18,6 @@ + diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs index 1e9d87a..54306e6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs @@ -1,8 +1,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts { /// - /// Represents an issued refresh token. - /// Always opaque and hashed at rest. + /// Transport model for refresh token. Returned to client once upon creation. /// public sealed class RefreshToken { diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs index 64e132d..d8724ba 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/SessionRefreshStatus.cs @@ -5,6 +5,6 @@ public enum SessionRefreshStatus Success, ReauthRequired, InvalidRequest, - Failed = 3 + Failed } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj new file mode 100644 index 0000000..9f31a13 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + + + + + + + + diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs new file mode 100644 index 0000000..e20fa10 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs @@ -0,0 +1,174 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal sealed class EfCoreTokenStore : ITokenStore +{ + private readonly UltimateAuthTokenDbContext _db; + private readonly EfCoreTokenStoreKernel _kernel; + private readonly ISessionStore _sessions; + private readonly ITokenHasher _hasher; + + public EfCoreTokenStore( + UltimateAuthTokenDbContext db, + EfCoreTokenStoreKernel kernel, + ISessionStore sessions, + ITokenHasher hasher) + { + _db = db; + _kernel = kernel; + _sessions = sessions; + _hasher = hasher; + } + + public Task StoreRefreshTokenAsync(string? tenantId, TUserId userId, AuthSessionId sessionId, string refreshTokenHash, DateTimeOffset expiresAt) + { + return _kernel.ExecuteAsync(ct => + { + _db.RefreshTokens.Add(new RefreshTokenProjection + { + TenantId = tenantId, + TokenHash = refreshTokenHash, + SessionId = sessionId, + ExpiresAt = expiresAt + }); + + return Task.CompletedTask; + }); + } + + public async Task> ValidateRefreshTokenAsync(string? tenantId, string providedRefreshToken, DateTimeOffset now) + { + var hash = _hasher.Hash(providedRefreshToken); + + return await _kernel.ExecuteAsync(async ct => + { + var token = await _db.RefreshTokens + .SingleOrDefaultAsync( + x => x.TokenHash == hash && + x.TenantId == tenantId, + ct); + + if (token is null) + return RefreshTokenValidationResult.Invalid(); + + if (token.RevokedAt != null) + return RefreshTokenValidationResult.ReuseDetected(); + + if (token.ExpiresAt <= now) + { + token.RevokedAt = now; + return RefreshTokenValidationResult.Invalid(); + } + + // Revoke on first use (rotation) + token.RevokedAt = now; + + var session = await _sessions.GetSessionAsync( + tenantId, + token.SessionId, + ct); + + if (session is null || + session.IsRevoked || + session.ExpiresAt <= now) + { + return RefreshTokenValidationResult.Invalid(); + } + + return RefreshTokenValidationResult.Valid( + session.UserId, + session.SessionId); + }); + } + + public Task RevokeRefreshTokenAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at) + { + return _kernel.ExecuteAsync(async ct => + { + var tokens = await _db.RefreshTokens + .Where(x => + x.TenantId == tenantId && + x.SessionId == sessionId && + x.RevokedAt == null) + .ToListAsync(ct); + + foreach (var token in tokens) + token.RevokedAt = at; + }); + } + + public Task RevokeAllRefreshTokensAsync(string? tenantId, TUserId _, DateTimeOffset at) + { + return _kernel.ExecuteAsync(async ct => + { + var tokens = await _db.RefreshTokens + .Where(x => + x.TenantId == tenantId && + x.RevokedAt == null) + .ToListAsync(ct); + + foreach (var token in tokens) + token.RevokedAt = at; + }); + } + + // ------------------------------------------------------------ + // JWT ID (JTI) + // ------------------------------------------------------------ + + public Task StoreTokenIdAsync(string? tenantId, string jti, DateTimeOffset expiresAt) + { + return _kernel.ExecuteAsync(ct => + { + _db.RevokedTokenIds.Add(new RevokedTokenIdProjection + { + TenantId = tenantId, + Jti = jti, + ExpiresAt = expiresAt, + RevokedAt = expiresAt + }); + + return Task.CompletedTask; + }); + } + + public async Task IsTokenIdRevokedAsync(string? tenantId, string jti) + { + return await _db.RevokedTokenIds + .AsNoTracking() + .AnyAsync(x => + x.Jti == jti && + x.TenantId == tenantId); + } + + public Task RevokeTokenIdAsync(string? tenantId, string jti, DateTimeOffset at) + { + return _kernel.ExecuteAsync(async ct => + { + var record = await _db.RevokedTokenIds + .SingleOrDefaultAsync( + x => x.Jti == jti && + x.TenantId == tenantId, + ct); + + if (record is null) + { + _db.RevokedTokenIds.Add(new RevokedTokenIdProjection + { + TenantId = tenantId, + Jti = jti, + ExpiresAt = at, + RevokedAt = at + }); + } + else + { + record.RevokedAt = at; + } + }); + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStoreKernel.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStoreKernel.cs new file mode 100644 index 0000000..0abe987 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStoreKernel.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore; +using System.Data; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal sealed class EfCoreTokenStoreKernel +{ + private readonly UltimateAuthTokenDbContext _db; + + public EfCoreTokenStoreKernel(UltimateAuthTokenDbContext db) + { + _db = db; + } + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + var connection = _db.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + await connection.OpenAsync(ct); + + await using var tx = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted,ct); + + _db.Database.UseTransaction(tx); + + try + { + await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + finally + { + _db.Database.UseTransaction(null); + } + } + + public async Task ExecuteAsync(Func> action,CancellationToken ct = default) + { + var connection = _db.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + await connection.OpenAsync(ct); + + await using var tx = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); + + _db.Database.UseTransaction(tx); + + try + { + var result = await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return result; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + finally + { + _db.Database.UseTransaction(null); + } + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs new file mode 100644 index 0000000..c3ed93d --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +// Add mapper class if needed (adding domain rules etc.) +internal sealed class RefreshTokenProjection +{ + public long Id { get; set; } // Surrogate PK + public string? TenantId { get; set; } + + public string TokenHash { get; set; } = default!; + public AuthSessionId SessionId { get; set; } + + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + + public byte[] RowVersion { get; set; } = default!; + + public bool IsRevoked => RevokedAt != null; +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs new file mode 100644 index 0000000..5499bac --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal sealed class RevokedTokenIdProjection +{ + public long Id { get; set; } + public string? TenantId { get; set; } + + public string Jti { get; set; } = default!; + + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset RevokedAt { get; set; } + + public byte[] RowVersion { get; set; } = default!; +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..4fff2ab --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthEntityFrameworkCoreTokens(this IServiceCollection services, Action configureDb) + { + services.AddDbContext(configureDb); + services.AddScoped(); + services.AddScoped(typeof(ITokenStore<>), typeof(EfCoreTokenStore<>)); + + return services; + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs new file mode 100644 index 0000000..d3cb5e3 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs @@ -0,0 +1,71 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal sealed class UltimateAuthTokenDbContext : DbContext +{ + public DbSet RefreshTokens => Set(); + public DbSet RevokedTokenIds => Set(); + + public UltimateAuthTokenDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder b) + { + // ------------------------------------------------- + // REFRESH TOKEN + // ------------------------------------------------- + b.Entity(e => + { + e.HasKey(x => x.Id); + + e.Property(x => x.RowVersion) + .IsRowVersion(); + + e.Property(x => x.TokenHash) + .IsRequired(); + + e.Property(x => x.SessionId) + .HasConversion( + v => v.Value, + v => new AuthSessionId(v)) + .IsRequired(); + + e.HasIndex(x => x.TokenHash) + .IsUnique(); + + e.HasIndex(x => new { x.TenantId, x.SessionId }); + + e.Property(x => x.ExpiresAt) + .IsRequired(); + }); + + // ------------------------------------------------- + // REVOKED JTI + // ------------------------------------------------- + b.Entity(e => + { + e.HasKey(x => x.Id); + + e.Property(x => x.RowVersion) + .IsRowVersion(); + + e.Property(x => x.Jti) + .IsRequired(); + + e.HasIndex(x => x.Jti) + .IsUnique(); + + e.HasIndex(x => new { x.TenantId, x.Jti }); + + e.Property(x => x.ExpiresAt) + .IsRequired(); + + e.Property(x => x.RevokedAt) + .IsRequired(); + }); + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj index 1f3e2de..df84dd4 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj @@ -7,7 +7,7 @@ true $(NoWarn);1591 - + diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs index 0e5f9b0..331bdf1 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryTokenStore.cs @@ -10,22 +10,14 @@ internal sealed class InMemoryTokenStore : ITokenStore private readonly ISessionStoreFactory _sessions; private readonly ITokenHasher _hasher; - public InMemoryTokenStore( - ITokenStoreFactory factory, - ISessionStoreFactory sessions, - ITokenHasher hasher) + public InMemoryTokenStore(ITokenStoreFactory factory, ISessionStoreFactory sessions, ITokenHasher hasher) { _factory = factory; _sessions = sessions; _hasher = hasher; } - public async Task StoreRefreshTokenAsync( - string? tenantId, - TUserId userId, - AuthSessionId sessionId, - string refreshTokenHash, - DateTimeOffset expiresAt) + public async Task StoreRefreshTokenAsync(string? tenantId, TUserId userId, AuthSessionId sessionId, string refreshTokenHash, DateTimeOffset expiresAt) { var kernel = _factory.Create(tenantId); @@ -58,7 +50,6 @@ public async Task> ValidateRefreshTokenAsy return RefreshTokenValidationResult.Invalid(); } - // one-time use await kernel.RevokeRefreshTokenAsync(tenantId, hash, now); var sessionKernel = _sessions.Create(tenantId); @@ -72,49 +63,32 @@ public async Task> ValidateRefreshTokenAsy session.SessionId); } - public Task RevokeRefreshTokenAsync( - string? tenantId, - AuthSessionId sessionId, - DateTimeOffset at) + public Task RevokeRefreshTokenAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at) { var kernel = _factory.Create(tenantId); return kernel.RevokeAllRefreshTokensAsync(tenantId, null, at); } - public Task RevokeAllRefreshTokensAsync( - string? tenantId, - TUserId _, - DateTimeOffset at) + public Task RevokeAllRefreshTokensAsync(string? tenantId, TUserId _, DateTimeOffset at) { var kernel = _factory.Create(tenantId); return kernel.RevokeAllRefreshTokensAsync(tenantId, null, at); } - // ------------------------------------------------------------ - // JTI - // ------------------------------------------------------------ - public Task StoreTokenIdAsync( - string? tenantId, - string jti, - DateTimeOffset expiresAt) + public Task StoreTokenIdAsync(string? tenantId, string jti, DateTimeOffset expiresAt) { var kernel = _factory.Create(tenantId); return kernel.StoreTokenIdAsync(tenantId, jti, expiresAt); } - public Task IsTokenIdRevokedAsync( - string? tenantId, - string jti) + public Task IsTokenIdRevokedAsync(string? tenantId, string jti) { var kernel = _factory.Create(tenantId); return kernel.IsTokenIdRevokedAsync(tenantId, jti); } - public Task RevokeTokenIdAsync( - string? tenantId, - string jti, - DateTimeOffset at) + public Task RevokeTokenIdAsync(string? tenantId, string jti, DateTimeOffset at) { var kernel = _factory.Create(tenantId); return kernel.RevokeTokenIdAsync(tenantId, jti, at); diff --git a/tests/CodeBeam.UltimateAuth.Core.Tests/UnitTest1.cs b/tests/CodeBeam.UltimateAuth.Core.Tests/UnitTest1.cs deleted file mode 100644 index acaced2..0000000 --- a/tests/CodeBeam.UltimateAuth.Core.Tests/UnitTest1.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Tests -{ - public class UnitTest1 - { - [Fact] - public void Test1() - { - - } - } -} diff --git a/tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj similarity index 92% rename from tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj rename to tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index 2653370..9858034 100644 --- a/tests/CodeBeam.UltimateAuth.Core.Tests/CodeBeam.UltimateAuth.Core.Tests.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -24,6 +24,7 @@ + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs new file mode 100644 index 0000000..e2d8214 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class AuthSessionIdTests +{ + [Fact] + public void Cannot_create_empty_session_id() + { + Assert.Throws(() => new AuthSessionId(string.Empty)); + } + + [Fact] + public void Equality_is_value_based() + { + var id1 = new AuthSessionId("abc"); + var id2 = new AuthSessionId("abc"); + + Assert.Equal(id1, id2); + Assert.True(id1 == id2); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs new file mode 100644 index 0000000..13011f5 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs @@ -0,0 +1,40 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthSessionChainTests +{ + [Fact] + public void Rotating_chain_increments_rotation_count() + { + var chain = UAuthSessionChain.Create( + ChainId.New(), + tenantId: null, + userId: "user-1", + securityVersion: 0, + ClaimsSnapshot.Empty); + + var rotated = chain.RotateSession(new AuthSessionId("s2")); + + Assert.Equal(1, rotated.RotationCount); + Assert.Equal("s2", rotated.ActiveSessionId?.Value); + } + + [Fact] + public void Revoked_chain_does_not_rotate() + { + var now = DateTimeOffset.UtcNow; + + var chain = UAuthSessionChain.Create( + ChainId.New(), + null, + "user-1", + 0, + ClaimsSnapshot.Empty); + + var revoked = chain.Revoke(now); + var rotated = revoked.RotateSession(new AuthSessionId("s2")); + + Assert.Same(revoked, rotated); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs new file mode 100644 index 0000000..1bf3f35 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs @@ -0,0 +1,52 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Xunit; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthSessionTests +{ + [Fact] + public void Revoke_marks_session_as_revoked() + { + var now = DateTimeOffset.UtcNow; + + var session = UAuthSession.Create( + new AuthSessionId("s1"), + tenantId: null, + userId: "user-1", + chainId: ChainId.New(), + now, + now.AddMinutes(10), + DeviceInfo.Unknown, + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + var revoked = session.Revoke(now); + + Assert.False(session.IsRevoked); + Assert.True(revoked.IsRevoked); + Assert.Equal(now, revoked.RevokedAt); + } + + [Fact] + public void Revoking_twice_returns_same_instance() + { + var now = DateTimeOffset.UtcNow; + + var session = UAuthSession.Create( + new AuthSessionId("s1"), + null, + "user-1", + ChainId.New(), + now, + now.AddMinutes(10), + DeviceInfo.Unknown, + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + var revoked1 = session.Revoke(now); + var revoked2 = revoked1.Revoke(now.AddMinutes(1)); + + Assert.Same(revoked1, revoked2); + } +} From 3bfe2a7674af1fdd86295feed8535fe0c7e65378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Tue, 30 Dec 2025 21:45:05 +0300 Subject: [PATCH 5/5] Add CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore & Implementation --- UltimateAuth.slnx | 1 + .../Abstractions/CredentialUserMapping.cs | 10 ++ .../AssemblyVisibility.cs | 3 + ...uth.Credentials.EntityFrameworkCore.csproj | 28 ++++++ .../Configuration/ConventionResolver.cs | 24 +++++ .../CredentialUserMappingBuilder.cs | 75 +++++++++++++++ .../CredentialUserMappingOptions.cs | 29 ++++++ .../EfCoreAuthUser.cs | 16 ++++ .../Infrastructure/EfCoreUserStore.cs | 83 ++++++++++++++++ .../ServiceCollectionExtensions.cs | 19 ++++ .../CodeBeam.UltimateAuth.Tests.Unit.csproj | 1 + .../CredentialUserMappingBuilderTests.cs | 95 +++++++++++++++++++ 12 files changed, 384 insertions(+) create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/AssemblyVisibility.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 5c95542..9f87acc 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -14,6 +14,7 @@ + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs new file mode 100644 index 0000000..5b8773f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class CredentialUserMapping +{ + public Func UserId { get; init; } = default!; + public Func Username { get; init; } = default!; + public Func PasswordHash { get; init; } = default!; + public Func SecurityVersion { get; init; } = default!; + public Func CanAuthenticate { get; init; } = default!; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/AssemblyVisibility.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/AssemblyVisibility.cs new file mode 100644 index 0000000..ed166fc --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj new file mode 100644 index 0000000..e73e9e5 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + 0.0.1-preview + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs new file mode 100644 index 0000000..4b2c9f2 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs @@ -0,0 +1,24 @@ +using System.Linq.Expressions; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore +{ + internal static class ConventionResolver + { + public static Expression>? TryResolve(params string[] names) + { + var prop = typeof(TUser) + .GetProperties() + .FirstOrDefault(p => + names.Contains(p.Name, StringComparer.OrdinalIgnoreCase) && + typeof(TProp).IsAssignableFrom(p.PropertyType)); + + if (prop is null) + return null; + + var param = Expression.Parameter(typeof(TUser), "u"); + var body = Expression.Property(param, prop); + + return Expression.Lambda>(body, param); + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs new file mode 100644 index 0000000..dfb653c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs @@ -0,0 +1,75 @@ +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore +{ + internal static class CredentialUserMappingBuilder + { + public static CredentialUserMapping Build(CredentialUserMappingOptions options) + { + if (options.UserId is null) + { + var expr = ConventionResolver.TryResolve("Id", "UserId"); + if (expr != null) + options.ApplyUserId(expr); + } + + if (options.Username is null) + { + var expr = ConventionResolver.TryResolve( + "Username", + "UserName", + "Email", + "EmailAddress", + "Login"); + + if (expr != null) + options.ApplyUsername(expr); + } + + // Never add "Password" as a convention to avoid accidental mapping to plaintext password properties + if (options.PasswordHash is null) + { + var expr = ConventionResolver.TryResolve( + "PasswordHash", + "Passwordhash", + "PasswordHashV2"); + + if (expr != null) + options.ApplyPasswordHash(expr); + } + + if (options.SecurityVersion is null) + { + var expr = ConventionResolver.TryResolve( + "SecurityVersion", + "SecurityStamp", + "AuthVersion"); + + if (expr != null) + options.ApplySecurityVersion(expr); + } + + + if (options.UserId is null) + throw new InvalidOperationException("UserId mapping is required. Use MapUserId(...) or ensure a conventional property exists."); + + if (options.Username is null) + throw new InvalidOperationException("Username mapping is required. Use MapUsername(...) or ensure a conventional property exists."); + + if (options.PasswordHash is null) + throw new InvalidOperationException("PasswordHash mapping is required. Use MapPasswordHash(...) or ensure a conventional property exists."); + + if (options.SecurityVersion is null) + throw new InvalidOperationException("SecurityVersion mapping is required. Use MapSecurityVersion(...) or ensure a conventional property exists."); + + var canAuthenticateExpr = options.CanAuthenticate ?? (_ => true); + + return new CredentialUserMapping + { + UserId = options.UserId.Compile(), + Username = options.Username.Compile(), + PasswordHash = options.PasswordHash.Compile(), + SecurityVersion = options.SecurityVersion.Compile(), + CanAuthenticate = canAuthenticateExpr.Compile() + }; + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs new file mode 100644 index 0000000..b8c326f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs @@ -0,0 +1,29 @@ +using System.Linq.Expressions; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +public sealed class CredentialUserMappingOptions +{ + internal Expression>? UserId { get; private set; } + internal Expression>? Username { get; private set; } + internal Expression>? PasswordHash { get; private set; } + internal Expression>? SecurityVersion { get; private set; } + internal Expression>? CanAuthenticate { get; private set; } + + public void MapUserId(Expression> expr) => UserId = expr; + public void MapUsername(Expression> expr) => Username = expr; + public void MapPasswordHash(Expression> expr) => PasswordHash = expr; + public void MapSecurityVersion(Expression> expr) => SecurityVersion = expr; + + /// + /// Optional. If not specified, all users are allowed to authenticate. + /// Use this to enforce custom user state rules (e.g. Active, Locked, Suspended). + /// Users that can't authenticate don't show up in authentication results. + /// + public void MapCanAuthenticate(Expression> expr) => CanAuthenticate = expr; + + internal void ApplyUserId(Expression> expr) => UserId = expr; + internal void ApplyUsername(Expression> expr) => Username = expr; + internal void ApplyPasswordHash(Expression> expr) => PasswordHash = expr; + internal void ApplySecurityVersion(Expression> expr) => SecurityVersion = expr; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs new file mode 100644 index 0000000..382cb1f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore +{ + internal sealed class EfCoreAuthUser : IUser + { + public TUserId UserId { get; } + + IReadOnlyDictionary? IUser.Claims => null; + + public EfCoreAuthUser(TUserId userId) + { + UserId = userId; + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs new file mode 100644 index 0000000..8a6fb28 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Infrastructure/EfCoreUserStore.cs @@ -0,0 +1,83 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class EfCoreUserStore : IUAuthUserStore where TUser : class +{ + private readonly DbContext _db; + private readonly CredentialUserMapping _map; + + public EfCoreUserStore(DbContext db, IOptions> options) + { + _db = db; + _map = CredentialUserMappingBuilder.Build(options.Value); + } + + public async Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + var user = await _db.Set().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct); + + if (user is null || !_map.CanAuthenticate(user)) + return null; + + return new EfCoreAuthUser(_map.UserId(user)); + } + + public async Task?> FindByUsernameAsync(string? tenantId, string username, CancellationToken ct = default) + { + var user = await _db.Set().FirstOrDefaultAsync(u => _map.Username(u) == username, ct); + + if (user is null || !_map.CanAuthenticate(user)) + return null; + + return new UserRecord + { + Id = _map.UserId(user), + Username = _map.Username(user), + PasswordHash = _map.PasswordHash(user), + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow, + IsDeleted = false + }; + } + + public async Task?> FindByLoginAsync(string? tenantId, string login, CancellationToken ct = default) + { + var user = await _db.Set().FirstOrDefaultAsync(u => _map.Username(u) == login, ct); + + if (user is null || !_map.CanAuthenticate(user)) + return null; + + return new EfCoreAuthUser(_map.UserId(user)); + } + + public Task GetPasswordHashAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + return _db.Set() + .Where(u => _map.UserId(u)!.Equals(userId)) + .Select(u => _map.PasswordHash(u)) + .FirstOrDefaultAsync(ct); + } + + public Task SetPasswordHashAsync(string? tenantId, TUserId userId, string passwordHash, CancellationToken token = default) + { + throw new NotSupportedException("Password updates are not supported by EfCoreUserStore. " + + "Use application-level user management services."); + } + + public async Task GetSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken ct = default) + { + var user = await _db.Set().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct); + return user is null ? 0 : _map.SecurityVersion(user); + } + + public Task IncrementSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default) + { + throw new NotSupportedException("Security version updates must be handled by the application."); + } + +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..3f7e4f1 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthEfCoreCredentials( + this IServiceCollection services, + Action> configure) + where TUser : class + { + services.Configure(configure); + + services.AddScoped, EfCoreUserStore>(); + + return services; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index 9858034..2bbcda1 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs new file mode 100644 index 0000000..3f8f07e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs @@ -0,0 +1,95 @@ +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit +{ + public class CredentialUserMappingBuilderTests + { + private sealed class ConventionUser + { + public Guid Id { get; set; } + public string Email { get; set; } = default!; + public string PasswordHash { get; set; } = default!; + public long SecurityVersion { get; set; } + } + + private sealed class ExplicitUser + { + public Guid UserId { get; set; } + public string LoginName { get; set; } = default!; + public string PasswordHash { get; set; } = default!; + public long SecurityVersion { get; set; } + } + + private sealed class PlainPasswordUser + { + public Guid Id { get; set; } + public string Username { get; set; } = default!; + public string Password { get; set; } = default!; + public long SecurityVersion { get; set; } + } + + + [Fact] + public void Build_UsesConventions_WhenExplicitMappingIsNotProvided() + { + var options = new CredentialUserMappingOptions(); + var mapping = CredentialUserMappingBuilder.Build(options); + var user = new ConventionUser + { + Id = Guid.NewGuid(), + Email = "test@example.com", + PasswordHash = "hash", + SecurityVersion = 3 + }; + + Assert.Equal(user.Id, mapping.UserId(user)); + Assert.Equal(user.Email, mapping.Username(user)); + Assert.Equal(user.PasswordHash, mapping.PasswordHash(user)); + Assert.Equal(user.SecurityVersion, mapping.SecurityVersion(user)); + Assert.True(mapping.CanAuthenticate(user)); + } + + [Fact] + public void Build_ExplicitMapping_OverridesConvention() + { + var options = new CredentialUserMappingOptions(); + options.MapUsername(u => u.LoginName); + var mapping = CredentialUserMappingBuilder.Build(options); + var user = new ExplicitUser + { + UserId = Guid.NewGuid(), + LoginName = "custom-login", + PasswordHash = "hash", + SecurityVersion = 1 + }; + + Assert.Equal("custom-login", mapping.Username(user)); + } + + [Fact] + public void Build_DoesNotMap_PlainPassword_Property() + { + var options = new CredentialUserMappingOptions(); + var ex = Assert.Throws(() => CredentialUserMappingBuilder.Build(options)); + + Assert.Contains("PasswordHash mapping is required", ex.Message); + } + + [Fact] + public void Build_Defaults_CanAuthenticate_ToTrue() + { + var options = new CredentialUserMappingOptions(); + var mapping = CredentialUserMappingBuilder.Build(options); + var user = new ConventionUser + { + Id = Guid.NewGuid(), + Email = "active@example.com", + PasswordHash = "hash", + SecurityVersion = 0 + }; + + var canAuthenticate = mapping.CanAuthenticate(user); + Assert.True(canAuthenticate); + } + } +}