From 3a4097ff960afe3e0524c6a0024a9b152797deb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 15 Mar 2026 02:22:26 +0300 Subject: [PATCH 1/3] EFCore Store Implementation --- .../Contracts/Authority/AccessContext.cs | 3 ++- .../Domain/Session/UAuthSession.cs | 3 ++- .../Services/UserRoleService.cs | 3 ++- .../CodeBeam.UltimateAuth.Authorization/Domain/Role.cs | 5 +++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index e526683e..7fa62828 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using System.Collections; @@ -29,7 +30,7 @@ public sealed class AccessContext public UserKey GetTargetUserKey() { if (TargetUserKey is not UserKey targetUserKey) - throw new InvalidOperationException("Target user is not found."); + throw new UAuthNotFoundException("Target user is not found."); return targetUserKey; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index 806a9c5b..fd3ee82a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Domain; @@ -138,7 +139,7 @@ public SessionState GetState(DateTimeOffset at) public UAuthSession WithChain(SessionChainId chainId) { if (!ChainId.IsUnassigned) - throw new InvalidOperationException("Chain already assigned."); + throw new UAuthConflictException("Chain already assigned."); return new UAuthSession( sessionId: SessionId, diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs index 5d987173..21f50c7b 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Server.Infrastructure; namespace CodeBeam.UltimateAuth.Authorization.Reference; @@ -33,7 +34,7 @@ public async Task AssignAsync(AccessContext context, UserKey targetUserKey, stri var role = await _roles.GetByNameAsync(context.ResourceTenant, normalized, innerCt); if (role is null || role.IsDeleted) - throw new InvalidOperationException("role_not_found"); + throw new UAuthNotFoundException("role_not_found"); await _userRoles.AssignAsync(context.ResourceTenant, targetUserKey, role.Id, now, innerCt); }); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs index b008663c..31f5a681 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Authorization.Domain; using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Authorization; @@ -35,7 +36,7 @@ public static Role Create( DateTimeOffset now) { if (string.IsNullOrWhiteSpace(name)) - throw new InvalidOperationException("role_name_required"); + throw new UAuthValidationException("role_name_required"); var normalized = Normalize(name); @@ -61,7 +62,7 @@ public static Role Create( public Role Rename(string newName, DateTimeOffset now) { if (string.IsNullOrWhiteSpace(newName)) - throw new InvalidOperationException("role_name_required"); + throw new UAuthValidationException("role_name_required"); if (NormalizedName == Normalize(newName)) return this; From da040c27d5ee274bb904f9347fbeba2fce618f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 15 Mar 2026 13:47:22 +0300 Subject: [PATCH 2/3] Completed EFCore Session Store --- .../Domain/Session/UAuthSession.cs | 14 +- .../Domain/Session/UAuthSessionChain.cs | 1 - .../Domain/Session/UAuthSessionRoot.cs | 11 +- .../Data/UAuthSessionDbContext.cs | 56 ++++++- .../SessionChainProjection.cs | 1 + .../EntityProjections/SessionProjection.cs | 5 +- .../SessionRootProjection.cs | 3 +- .../Extensions/ServiceCollectionExtensions.cs | 9 +- .../Mappers/SessionChainProjectionMapper.cs | 4 + .../Mappers/SessionProjectionMapper.cs | 4 - .../Mappers/SessionRootProjectionMapper.cs | 3 - .../Stores/EfCoreSessionStore.cs | 147 +++++------------- 12 files changed, 115 insertions(+), 143 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index fd3ee82a..82076f6c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -4,7 +4,6 @@ namespace CodeBeam.UltimateAuth.Core.Domain; -// TODO: Add ISoftDeleteable public sealed class UAuthSession : IVersionedEntity { public AuthSessionId SessionId { get; } @@ -13,13 +12,14 @@ public sealed class UAuthSession : IVersionedEntity public SessionChainId ChainId { get; } public DateTimeOffset CreatedAt { get; } public DateTimeOffset ExpiresAt { get; } - public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } public long SecurityVersionAtCreation { get; } public ClaimsSnapshot Claims { get; } public SessionMetadata Metadata { get; } public long Version { get; set; } + public bool IsRevoked => RevokedAt != null; + private UAuthSession( AuthSessionId sessionId, TenantKey tenant, @@ -27,7 +27,6 @@ private UAuthSession( SessionChainId chainId, DateTimeOffset createdAt, DateTimeOffset expiresAt, - bool isRevoked, DateTimeOffset? revokedAt, long securityVersionAtCreation, ClaimsSnapshot claims, @@ -40,7 +39,6 @@ private UAuthSession( ChainId = chainId; CreatedAt = createdAt; ExpiresAt = expiresAt; - IsRevoked = isRevoked; RevokedAt = revokedAt; SecurityVersionAtCreation = securityVersionAtCreation; Claims = claims; @@ -66,7 +64,6 @@ public static UAuthSession Create( chainId, createdAt: now, expiresAt: expiresAt, - isRevoked: false, revokedAt: null, securityVersionAtCreation: securityVersion, claims: claims ?? ClaimsSnapshot.Empty, @@ -77,7 +74,8 @@ public static UAuthSession Create( public UAuthSession Revoke(DateTimeOffset at) { - if (IsRevoked) return this; + if (IsRevoked) + return this; return new UAuthSession( SessionId, @@ -86,7 +84,6 @@ public UAuthSession Revoke(DateTimeOffset at) ChainId, CreatedAt, ExpiresAt, - true, at, SecurityVersionAtCreation, Claims, @@ -102,7 +99,6 @@ internal static UAuthSession FromProjection( SessionChainId chainId, DateTimeOffset createdAt, DateTimeOffset expiresAt, - bool isRevoked, DateTimeOffset? revokedAt, long securityVersionAtCreation, ClaimsSnapshot claims, @@ -116,7 +112,6 @@ internal static UAuthSession FromProjection( chainId, createdAt, expiresAt, - isRevoked, revokedAt, securityVersionAtCreation, claims, @@ -148,7 +143,6 @@ public UAuthSession WithChain(SessionChainId chainId) chainId: chainId, createdAt: CreatedAt, expiresAt: ExpiresAt, - isRevoked: IsRevoked, revokedAt: RevokedAt, securityVersionAtCreation: SecurityVersionAtCreation, claims: Claims, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index 7d9139cc..e211cf04 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -1,6 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using static CodeBeam.UltimateAuth.Core.Defaults.UAuthActions; namespace CodeBeam.UltimateAuth.Core.Domain; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs index 0ae69287..d50f9208 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -11,20 +11,19 @@ public sealed class UAuthSessionRoot : IVersionedEntity public DateTimeOffset CreatedAt { get; } public DateTimeOffset? UpdatedAt { get; } - - public bool IsRevoked { get; } public DateTimeOffset? RevokedAt { get; } public long SecurityVersion { get; } public long Version { get; set; } + public bool IsRevoked => RevokedAt != null; + private UAuthSessionRoot( SessionRootId rootId, TenantKey tenant, UserKey userKey, DateTimeOffset createdAt, DateTimeOffset? updatedAt, - bool isRevoked, DateTimeOffset? revokedAt, long securityVersion, long version) @@ -34,7 +33,6 @@ private UAuthSessionRoot( UserKey = userKey; CreatedAt = createdAt; UpdatedAt = updatedAt; - IsRevoked = isRevoked; RevokedAt = revokedAt; SecurityVersion = securityVersion; Version = version; @@ -51,7 +49,6 @@ public static UAuthSessionRoot Create( userKey, at, null, - false, null, 0, 0 @@ -66,7 +63,6 @@ public UAuthSessionRoot IncreaseSecurityVersion(DateTimeOffset at) UserKey, CreatedAt, at, - IsRevoked, RevokedAt, SecurityVersion + 1, Version + 1 @@ -84,7 +80,6 @@ public UAuthSessionRoot Revoke(DateTimeOffset at) UserKey, CreatedAt, at, - true, at, SecurityVersion + 1, Version + 1 @@ -97,7 +92,6 @@ internal static UAuthSessionRoot FromProjection( UserKey userKey, DateTimeOffset createdAt, DateTimeOffset? updatedAt, - bool isRevoked, DateTimeOffset? revokedAt, long securityVersion, long version) @@ -108,7 +102,6 @@ internal static UAuthSessionRoot FromProjection( userKey, createdAt, updatedAt, - isRevoked, revokedAt, securityVersion, version diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs index b7f0977d..b2dce495 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs @@ -34,8 +34,13 @@ protected override void OnModelCreating(ModelBuilder b) e.HasKey(x => x.Id); e.Property(x => x.Version).IsConcurrencyToken(); - - e.Property(x => x.UserKey) + e.Property(x => x.UserKey).IsRequired(); + e.Property(x => x.CreatedAt).IsRequired(); + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) .IsRequired(); e.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique(); @@ -48,6 +53,7 @@ protected override void OnModelCreating(ModelBuilder b) .HasConversion( v => v.Value, v => SessionRootId.From(v)) + .HasMaxLength(128) .IsRequired(); }); @@ -56,11 +62,25 @@ protected override void OnModelCreating(ModelBuilder b) e.HasKey(x => x.Id); e.Property(x => x.Version).IsConcurrencyToken(); - - e.Property(x => x.UserKey) + e.Property(x => x.UserKey).IsRequired(); + e.Property(x => x.CreatedAt).IsRequired(); + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) .IsRequired(); e.HasIndex(x => new { x.Tenant, x.ChainId }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.UserKey }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.DeviceId }); + e.HasIndex(x => new { x.Tenant, x.RootId }); + + e.HasOne() + .WithMany() + .HasForeignKey(x => new { x.Tenant, x.RootId }) + .HasPrincipalKey(x => new { x.Tenant, x.RootId }) + .OnDelete(DeleteBehavior.Restrict); e.Property(x => x.ChainId) .HasConversion( @@ -68,6 +88,17 @@ protected override void OnModelCreating(ModelBuilder b) v => SessionChainId.From(v)) .IsRequired(); + e.Property(x => x.DeviceId) + .HasConversion( + v => v.Value, + v => DeviceId.Create(v)) + .HasMaxLength(64) + .IsRequired(); + + e.Property(x => x.Device) + .HasConversion(new JsonValueConverter()) + .IsRequired(); + e.Property(x => x.ActiveSessionId) .HasConversion(new NullableAuthSessionIdConverter()); @@ -83,9 +114,26 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); e.Property(x => x.Version).IsConcurrencyToken(); + e.Property(x => x.CreatedAt).IsRequired(); + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); e.HasIndex(x => new { x.Tenant, x.SessionId }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.ChainId }); e.HasIndex(x => new { x.Tenant, x.ChainId, x.RevokedAt }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.RevokedAt }); + e.HasIndex(x => new { x.Tenant, x.ExpiresAt }); + e.HasIndex(x => new { x.Tenant, x.RevokedAt }); + + e.HasOne() + .WithMany() + .HasForeignKey(x => new { x.Tenant, x.ChainId }) + .HasPrincipalKey(x => new { x.Tenant, x.ChainId }) + .OnDelete(DeleteBehavior.Restrict); e.Property(x => x.SessionId) .HasConversion(new AuthSessionIdConverter()) diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs index 7106b024..6da8b412 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs @@ -15,6 +15,7 @@ internal sealed class SessionChainProjection public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset LastSeenAt { get; set; } public DateTimeOffset? AbsoluteExpiresAt { get; set; } + public DeviceId DeviceId { get; set; } public DeviceContext Device { get; set; } public ClaimsSnapshot ClaimsSnapshot { get; set; } = ClaimsSnapshot.Empty; public AuthSessionId? ActiveSessionId { get; set; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs index 58c37604..19061c50 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs @@ -15,9 +15,8 @@ internal sealed class SessionProjection 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; } @@ -27,4 +26,6 @@ internal sealed class SessionProjection public SessionMetadata Metadata { get; set; } = SessionMetadata.Empty; public long Version { get; set; } + + public bool IsRevoked => RevokedAt != null; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs index 5d1e613f..4d9c0ff8 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs @@ -13,9 +13,10 @@ internal sealed class SessionRootProjection public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset? UpdatedAt { get; set; } - public bool IsRevoked { get; set; } public DateTimeOffset? RevokedAt { get; set; } public long SecurityVersion { get; set; } public long Version { get; set; } + + public bool IsRevoked => RevokedAt != null; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 478a44e6..82bb21d0 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -1,14 +1,15 @@ -using Microsoft.EntityFrameworkCore; +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 + public static IServiceCollection AddUltimateAuthEntityFrameworkCoreSessions(this IServiceCollection services,Action configureDb) { - services.AddDbContext(configureDb); - services.AddScoped(); + services.AddDbContextPool(configureDb); + services.AddScoped(); return services; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs index 683f9453..271f5c75 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -27,6 +27,9 @@ public static UAuthSessionChain ToDomain(this SessionChainProjection p) public static SessionChainProjection ToProjection(this UAuthSessionChain chain) { + if (chain.Device.DeviceId is not DeviceId deviceId) + throw new ArgumentException("Device id required."); + return new SessionChainProjection { ChainId = chain.ChainId, @@ -36,6 +39,7 @@ public static SessionChainProjection ToProjection(this UAuthSessionChain chain) CreatedAt = chain.CreatedAt, LastSeenAt = chain.LastSeenAt, AbsoluteExpiresAt = chain.AbsoluteExpiresAt, + DeviceId = deviceId, Device = chain.Device, ClaimsSnapshot = chain.ClaimsSnapshot, ActiveSessionId = chain.ActiveSessionId, diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs index 9b591691..0c027d8f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -13,7 +13,6 @@ public static UAuthSession ToDomain(this SessionProjection p) p.ChainId, p.CreatedAt, p.ExpiresAt, - p.IsRevoked, p.RevokedAt, p.SecurityVersionAtCreation, p.Claims, @@ -33,8 +32,6 @@ public static SessionProjection ToProjection(this UAuthSession s) CreatedAt = s.CreatedAt, ExpiresAt = s.ExpiresAt, - - IsRevoked = s.IsRevoked, RevokedAt = s.RevokedAt, SecurityVersionAtCreation = s.SecurityVersionAtCreation, @@ -43,5 +40,4 @@ public static SessionProjection ToProjection(this UAuthSession s) Version = s.Version }; } - } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs index 63d1fa41..d38a02a5 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs @@ -12,7 +12,6 @@ public static UAuthSessionRoot ToDomain(this SessionRootProjection root) root.UserKey, root.CreatedAt, root.UpdatedAt, - root.IsRevoked, root.RevokedAt, root.SecurityVersion, root.Version @@ -29,8 +28,6 @@ public static SessionRootProjection ToProjection(this UAuthSessionRoot root) CreatedAt = root.CreatedAt, UpdatedAt = root.UpdatedAt, - - IsRevoked = root.IsRevoked, RevokedAt = root.RevokedAt, SecurityVersion = root.SecurityVersion, diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index 65a2a1db..d52c232f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -10,12 +10,10 @@ namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; internal sealed class EfCoreSessionStore : ISessionStore { private readonly UltimateAuthSessionDbContext _db; - private readonly TenantContext _tenant; - public EfCoreSessionStore(UltimateAuthSessionDbContext db, TenantContext tenant) + public EfCoreSessionStore(UltimateAuthSessionDbContext db) { _db = db; - _tenant = tenant; } public async Task ExecuteAsync(Func action, CancellationToken ct = default) @@ -24,12 +22,7 @@ public async Task ExecuteAsync(Func action, Cancellatio 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); + await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); try { @@ -47,10 +40,6 @@ await strategy.ExecuteAsync(async () => await tx.RollbackAsync(ct); throw; } - finally - { - _db.Database.UseTransaction(null); - } }); } @@ -60,12 +49,7 @@ public async Task ExecuteAsync(Func { - 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); + await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); try { @@ -74,14 +58,15 @@ public async Task ExecuteAsync(Func ExecuteAsync(Func x.Version).OriginalValue = expectedVersion; - - try - { - await Task.CompletedTask; - } - catch (DbUpdateConcurrencyException) - { - throw new UAuthConcurrencyException("session_concurrency_conflict"); - } + _db.Entry(projection).State = EntityState.Modified; + return Task.CompletedTask; } public async Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default) @@ -133,18 +109,13 @@ public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffs { ct.ThrowIfCancellationRequested(); - var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId); - - if (projection is null) - return false; + var projection = await _db.Sessions.SingleOrDefaultAsync(x => x.SessionId == sessionId, ct); - var session = projection.ToDomain(); - if (session.IsRevoked) + if (projection is null || projection.IsRevoked) return false; - var revoked = session.Revoke(at); + var revoked = projection.ToDomain().Revoke(at); _db.Sessions.Update(revoked.ToProjection()); - return true; } @@ -153,29 +124,23 @@ public async Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, Cancel ct.ThrowIfCancellationRequested(); var chains = await _db.Chains.Where(x => x.UserKey == user).ToListAsync(ct); + var chainIds = chains.Select(x => x.ChainId).ToList(); + var sessions = await _db.Sessions.Where(x => chainIds.Contains(x.ChainId)).ToListAsync(ct); - foreach (var chainProjection in chains) + foreach (var sessionProjection in sessions) { - var chain = chainProjection.ToDomain(); - - var sessions = await _db.Sessions.Where(x => x.ChainId == chain.ChainId).ToListAsync(ct); - - foreach (var sessionProjection in sessions) - { - var session = sessionProjection.ToDomain(); + var session = sessionProjection.ToDomain(); - if (session.IsRevoked) - continue; + if (!session.IsRevoked) + _db.Sessions.Update(session.Revoke(at).ToProjection()); + } - var revoked = session.Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); - } + foreach (var chainProjection in chains) + { + var chain = chainProjection.ToDomain(); if (chain.ActiveSessionId is not null) - { - var updatedChain = chain.DetachSession(at); - _db.Chains.Update(updatedChain.ToProjection()); - } + _db.Chains.Update(chain.DetachSession(at).ToProjection()); } } @@ -184,29 +149,23 @@ public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChai ct.ThrowIfCancellationRequested(); var chains = await _db.Chains.Where(x => x.UserKey == user && x.ChainId != keepChain).ToListAsync(ct); + var chainIds = chains.Select(x => x.ChainId).ToList(); + var sessions = await _db.Sessions.Where(x => chainIds.Contains(x.ChainId)).ToListAsync(ct); - foreach (var chainProjection in chains) + foreach (var sessionProjection in sessions) { - var chain = chainProjection.ToDomain(); - - var sessions = await _db.Sessions.Where(x => x.ChainId == chain.ChainId).ToListAsync(ct); - - foreach (var sessionProjection in sessions) - { - var session = sessionProjection.ToDomain(); + var session = sessionProjection.ToDomain(); - if (session.IsRevoked) - continue; + if (!session.IsRevoked) + _db.Sessions.Update(session.Revoke(at).ToProjection()); + } - var revoked = session.Revoke(at); - _db.Sessions.Update(revoked.ToProjection()); - } + foreach (var chainProjection in chains) + { + var chain = chainProjection.ToDomain(); if (chain.ActiveSessionId is not null) - { - var updatedChain = chain.DetachSession(at); - _db.Chains.Update(updatedChain.ToProjection()); - } + _db.Chains.Update(chain.DetachSession(at).ToProjection()); } } @@ -231,7 +190,7 @@ public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChai x.Tenant == tenant && x.UserKey == userKey && x.RevokedAt == null && - x.Device.DeviceId == deviceId) + x.DeviceId == deviceId) .SingleOrDefaultAsync(ct); return projection?.ToDomain(); @@ -392,19 +351,8 @@ public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId { ct.ThrowIfCancellationRequested(); - var rootProjection = await _db.Roots - .AsNoTracking() - .SingleOrDefaultAsync(x => x.UserKey == userKey); - - if (rootProjection is null) - return null; - - var chains = await _db.Chains - .AsNoTracking() - .Where(x => x.UserKey == userKey) - .ToListAsync(); - - return rootProjection.ToDomain(); + var rootProjection = await _db.Roots.AsNoTracking().SingleOrDefaultAsync(x => x.UserKey == userKey, ct); + return rootProjection?.ToDomain(); } public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default) @@ -511,19 +459,8 @@ public async Task> GetSessionsByChainAsync(SessionCh { ct.ThrowIfCancellationRequested(); - var rootProjection = await _db.Roots - .AsNoTracking() - .SingleOrDefaultAsync(x => x.RootId == rootId); - - if (rootProjection is null) - return null; - - var chains = await _db.Chains - .AsNoTracking() - .Where(x => x.RootId == rootId) - .ToListAsync(); - - return rootProjection.ToDomain(); + var projection = await _db.Roots.AsNoTracking().SingleOrDefaultAsync(x => x.RootId == rootId, ct); + return projection?.ToDomain(); } public async Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) From b3123c9a56bfb4d2c43a4db2db0dbfdcf91ac1a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 15 Mar 2026 16:15:00 +0300 Subject: [PATCH 3/3] Completed EFCore Token Store --- UltimateAuth.slnx | 1 + .../Abstractions/Stores/IRefreshTokenStore.cs | 21 +-- .../Stores/IRefreshTokenStoreFactory.cs | 8 + .../AssemblyVisibility.cs | 1 + .../Contracts/Login/LoginResult.cs | 2 +- .../Contracts/Refresh/RefreshFlowResult.cs | 4 +- .../Contracts/Token/AuthTokens.cs | 2 +- .../{RefreshToken.cs => RefreshTokenInfo.cs} | 2 +- .../Token/RefreshTokenRotationResult.cs | 4 +- .../Domain/Device/DeviceContext.cs | 10 +- .../Domain/Session/UAuthSession.cs | 9 + .../Domain/Token/RefreshToken.cs | 88 +++++++++ .../Domain/Token/StoredRefreshToken.cs | 36 ---- .../Domain/Token/TokenId.cs | 51 +++++ .../AuthSessionIdJsonConverter.cs | 0 .../SessionChainIdJsonConverter.cs | 0 .../SessionRootIdJsonConverter.cs | 0 .../TenantKeyJsonConverter.cs | 0 .../Converters/TokenIdJsonConverter.cs | 26 +++ .../{ => Converters}/UAuthUserIdConverter.cs | 0 .../{ => Converters}/UserKeyJsonConverter.cs | 0 .../UAuthRefreshTokenValidator.cs | 11 +- .../Abstractions/ICredentialResponseWriter.cs | 2 +- .../Abstractions/ITokenIssuer.cs | 2 +- .../Credentials/CredentialResponseWriter.cs | 2 +- .../Issuers/UAuthSessionIssuer.cs | 2 + .../Issuers/UAuthTokenIssuer.cs | 41 ++-- .../Services/RefreshTokenRotationService.cs | 62 +++--- ...th.EntityFrameworkCore.Abstractions.csproj | 28 +++ .../AuthSessionIdEfConverter.cs | 5 +- .../Infrastructure/JsonValueConverter.cs | 4 +- .../NullableAuthSessionIdConverter.cs | 6 +- .../NullableSessionChainIdConverter.cs | 14 ++ .../Infrastructure/SessionChainIdConverter.cs | 14 ++ .../SessionChainIdEfConverter.cs | 23 +++ ...teAuth.Sessions.EntityFrameworkCore.csproj | 14 +- .../Data/UAuthSessionDbContext.cs | 1 + .../Mappers/SessionProjectionMapper.cs | 2 + .../SessionChainProjection.cs | 2 +- .../SessionProjection.cs | 0 .../SessionRootProjection.cs | 0 ...mateAuth.Tokens.EntityFrameworkCore.csproj | 14 +- .../{ => Data}/UAuthTokenDbContext.cs | 51 ++--- .../EfCoreTokenStore.cs | 110 ----------- .../ServiceCollectionExtensions.cs | 4 +- .../Mappers/RefreshTokenProjectionMapper.cs | 43 +++++ .../Projections/RefreshTokenProjection.cs | 15 +- .../Stores/EfCoreRefreshTokenStore.cs | 162 ++++++++++++++++ .../Stores/EfCoreRefreshTokenStoreFactory.cs | 20 ++ .../InMemoryRefreshTokenStore.cs | 109 +++++++---- .../InMemoryRefreshTokenStoreFactory.cs | 15 ++ .../ServiceCollectionExtensions.cs | 2 +- .../Core/RefreshTokenValidatorTests.cs | 176 +++++++++++++++--- .../Core/UAuthSessionTests.cs | 2 + .../Helpers/TestIds.cs | 5 + 55 files changed, 868 insertions(+), 360 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStoreFactory.cs rename src/CodeBeam.UltimateAuth.Core/Contracts/Token/{RefreshToken.cs => RefreshTokenInfo.cs} (93%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Token/RefreshToken.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenId.cs rename src/CodeBeam.UltimateAuth.Core/Infrastructure/{ => Converters}/AuthSessionIdJsonConverter.cs (100%) rename src/CodeBeam.UltimateAuth.Core/Infrastructure/{ => Converters}/SessionChainIdJsonConverter.cs (100%) rename src/CodeBeam.UltimateAuth.Core/Infrastructure/{ => Converters}/SessionRootIdJsonConverter.cs (100%) rename src/CodeBeam.UltimateAuth.Core/Infrastructure/{ => Converters}/TenantKeyJsonConverter.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TokenIdJsonConverter.cs rename src/CodeBeam.UltimateAuth.Core/Infrastructure/{ => Converters}/UAuthUserIdConverter.cs (100%) rename src/CodeBeam.UltimateAuth.Core/Infrastructure/{ => Converters}/UserKeyJsonConverter.cs (100%) create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.csproj rename src/{sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore => persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions}/Infrastructure/AuthSessionIdEfConverter.cs (84%) rename src/{sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore => persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions}/Infrastructure/JsonValueConverter.cs (69%) rename src/{sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore => persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions}/Infrastructure/NullableAuthSessionIdConverter.cs (64%) create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableSessionChainIdConverter.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdConverter.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdEfConverter.cs rename src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/{EntityProjections => Projections}/SessionChainProjection.cs (94%) rename src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/{EntityProjections => Projections}/SessionProjection.cs (100%) rename src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/{EntityProjections => Projections}/SessionRootProjection.cs (100%) rename src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/{ => Data}/UAuthTokenDbContext.cs (50%) delete mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs rename src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/{ => Extensions}/ServiceCollectionExtensions.cs (72%) create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Mappers/RefreshTokenProjectionMapper.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs create mode 100644 src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStoreFactory.cs diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 22c26b0d..d9db4f02 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -26,6 +26,7 @@ + diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs index cf43b46a..fb8693c1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs @@ -1,23 +1,22 @@ using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Core.Abstractions; -/// -/// Low-level persistence abstraction for refresh tokens. -/// NO validation logic. NO business rules. -/// public interface IRefreshTokenStore { - Task StoreAsync(TenantKey tenant, StoredRefreshToken token, CancellationToken ct = default); + Task ExecuteAsync(Func action, CancellationToken ct = default); - Task FindByHashAsync(TenantKey tenant, string tokenHash, CancellationToken ct = default); + Task ExecuteAsync(Func> action, CancellationToken ct = default); - Task RevokeAsync(TenantKey tenant, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default); + Task StoreAsync(RefreshToken token, CancellationToken ct = default); - Task RevokeBySessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default); + Task FindByHashAsync(string tokenHash, CancellationToken ct = default); - Task RevokeByChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default); + Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default); - Task RevokeAllForUserAsync(TenantKey tenant, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default); + Task RevokeBySessionAsync(AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default); + + Task RevokeByChainAsync(SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default); + + Task RevokeAllForUserAsync(UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStoreFactory.cs new file mode 100644 index 00000000..e37debc7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStoreFactory.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IRefreshTokenStoreFactory +{ + IRefreshTokenStore Create(TenantKey tenant); +} diff --git a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs index 768f9e27..68f21567 100644 --- a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs +++ b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs @@ -2,4 +2,5 @@ [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Server")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs index 93c84a09..f9d3b280 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs @@ -7,7 +7,7 @@ public sealed record LoginResult public LoginStatus Status { get; init; } public AuthSessionId? SessionId { get; init; } public AccessToken? AccessToken { get; init; } - public RefreshToken? RefreshToken { get; init; } + public RefreshTokenInfo? RefreshToken { get; init; } public LoginContinuation? Continuation { get; init; } public AuthFailureReason? FailureReason { get; init; } public DateTimeOffset? LockoutUntilUtc { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs index 51b81396..59b18af1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs @@ -9,7 +9,7 @@ public sealed class RefreshFlowResult public AuthSessionId? SessionId { get; init; } public AccessToken? AccessToken { get; init; } - public RefreshToken? RefreshToken { get; init; } + public RefreshTokenInfo? RefreshToken { get; init; } public static RefreshFlowResult ReauthRequired() { @@ -24,7 +24,7 @@ public static RefreshFlowResult Success( RefreshOutcome outcome, AuthSessionId? sessionId = null, AccessToken? accessToken = null, - RefreshToken? refreshToken = null) + RefreshTokenInfo? refreshToken = null) { return new RefreshFlowResult { diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs index be61e290..1b13c185 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs @@ -12,5 +12,5 @@ public sealed record AuthTokens /// public AccessToken AccessToken { get; init; } = default!; - public RefreshToken? RefreshToken { get; init; } + public RefreshTokenInfo? RefreshToken { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs index d741b858..6d5648b5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs @@ -3,7 +3,7 @@ /// /// Transport model for refresh token. Returned to client once upon creation. /// -public sealed class RefreshToken +public sealed class RefreshTokenInfo { /// /// Plain refresh token value (returned to client once). diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs index b1c50d0d..af715f12 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs @@ -10,7 +10,7 @@ public sealed record RefreshTokenRotationResult public AuthSessionId? SessionId { get; init; } public AccessToken? AccessToken { get; init; } - public RefreshToken? RefreshToken { get; init; } + public RefreshTokenInfo? RefreshToken { get; init; } private RefreshTokenRotationResult() { } @@ -18,7 +18,7 @@ private RefreshTokenRotationResult() { } public static RefreshTokenRotationResult Success( AccessToken accessToken, - RefreshToken refreshToken) + RefreshTokenInfo refreshToken) => new() { IsSuccess = true, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs index 23372748..f3d3049c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs @@ -38,11 +38,11 @@ public static DeviceContext Anonymous() public static DeviceContext Create( DeviceId deviceId, - string? deviceType, - string? platform, - string? operatingSystem, - string? browser, - string? ipAddress) + string? deviceType = null, + string? platform = null, + string? operatingSystem = null, + string? browser = null, + string? ipAddress = null) { return new DeviceContext( deviceId, diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index 82076f6c..b9351524 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -14,6 +14,7 @@ public sealed class UAuthSession : IVersionedEntity public DateTimeOffset ExpiresAt { get; } public DateTimeOffset? RevokedAt { get; } public long SecurityVersionAtCreation { get; } + public DeviceContext Device { get; } // For snapshot,main value is chain's device. public ClaimsSnapshot Claims { get; } public SessionMetadata Metadata { get; } public long Version { get; set; } @@ -29,6 +30,7 @@ private UAuthSession( DateTimeOffset expiresAt, DateTimeOffset? revokedAt, long securityVersionAtCreation, + DeviceContext device, ClaimsSnapshot claims, SessionMetadata metadata, long version) @@ -41,6 +43,7 @@ private UAuthSession( ExpiresAt = expiresAt; RevokedAt = revokedAt; SecurityVersionAtCreation = securityVersionAtCreation; + Device = device; Claims = claims; Metadata = metadata; Version = version; @@ -54,6 +57,7 @@ public static UAuthSession Create( DateTimeOffset now, DateTimeOffset expiresAt, long securityVersion, + DeviceContext device, ClaimsSnapshot? claims, SessionMetadata metadata) { @@ -66,6 +70,7 @@ public static UAuthSession Create( expiresAt: expiresAt, revokedAt: null, securityVersionAtCreation: securityVersion, + device: device, claims: claims ?? ClaimsSnapshot.Empty, metadata: metadata, version: 0 @@ -86,6 +91,7 @@ public UAuthSession Revoke(DateTimeOffset at) ExpiresAt, at, SecurityVersionAtCreation, + Device, Claims, Metadata, Version + 1 @@ -101,6 +107,7 @@ internal static UAuthSession FromProjection( DateTimeOffset expiresAt, DateTimeOffset? revokedAt, long securityVersionAtCreation, + DeviceContext device, ClaimsSnapshot claims, SessionMetadata metadata, long version) @@ -114,6 +121,7 @@ internal static UAuthSession FromProjection( expiresAt, revokedAt, securityVersionAtCreation, + device, claims, metadata, version @@ -145,6 +153,7 @@ public UAuthSession WithChain(SessionChainId chainId) expiresAt: ExpiresAt, revokedAt: RevokedAt, securityVersionAtCreation: SecurityVersionAtCreation, + device: Device, claims: Claims, metadata: Metadata, version: Version + 1 diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/RefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/RefreshToken.cs new file mode 100644 index 00000000..4cf354c8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/RefreshToken.cs @@ -0,0 +1,88 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed record RefreshToken : IVersionedEntity +{ + public TokenId TokenId { get; init; } + + public string TokenHash { get; init; } = default!; + + public TenantKey Tenant { get; init; } + + public required UserKey UserKey { get; init; } + + public AuthSessionId SessionId { get; init; } + + public SessionChainId? ChainId { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset ExpiresAt { get; init; } + + public DateTimeOffset? RevokedAt { get; init; } + + public string? ReplacedByTokenHash { get; init; } + + public long Version { get; set; } + + public bool IsRevoked => RevokedAt.HasValue; + + public bool IsExpired(DateTimeOffset now) + => ExpiresAt <= now; + + public bool IsActive(DateTimeOffset now) + => !IsRevoked && !IsExpired(now) && ReplacedByTokenHash is null; + + public static RefreshToken Create( + TokenId tokenId, + string tokenHash, + TenantKey tenant, + UserKey userKey, + AuthSessionId sessionId, + SessionChainId? chainId, + DateTimeOffset createdAt, + DateTimeOffset expiresAt) + { + return new RefreshToken + { + TokenId = tokenId, + TokenHash = tokenHash, + Tenant = tenant, + UserKey = userKey, + SessionId = sessionId, + ChainId = chainId, + CreatedAt = createdAt, + ExpiresAt = expiresAt, + Version = 0 + }; + } + + public RefreshToken Revoke(DateTimeOffset at, string? replacedBy = null) + { + if (IsRevoked) + return this; + + return this with + { + RevokedAt = at, + ReplacedByTokenHash = replacedBy, + Version = Version + 1 + }; + } + + public RefreshToken Replace(string newTokenHash, DateTimeOffset at) + { + if (IsRevoked) + throw new UAuthConflictException("Token already revoked."); + + return this with + { + RevokedAt = at, + ReplacedByTokenHash = newTokenHash, + Version = Version + 1 + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs deleted file mode 100644 index bd21a678..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs +++ /dev/null @@ -1,36 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using System.ComponentModel.DataAnnotations.Schema; - -namespace CodeBeam.UltimateAuth.Core.Domain; - -/// -/// Represents a persisted refresh token bound to a session. -/// Stored as a hashed value for security reasons. -/// -public sealed record StoredRefreshToken : IVersionedEntity -{ - public string TokenHash { get; init; } = default!; - - public TenantKey Tenant { get; init; } - - public required UserKey UserKey { get; init; } - - public AuthSessionId SessionId { get; init; } = default!; - public SessionChainId? ChainId { get; init; } - - public DateTimeOffset IssuedAt { get; init; } - public DateTimeOffset ExpiresAt { get; init; } - public DateTimeOffset? RevokedAt { get; init; } - - public string? ReplacedByTokenHash { get; init; } - - public long Version { get; set; } - - [NotMapped] - public bool IsRevoked => RevokedAt.HasValue; - - public bool IsExpired(DateTimeOffset now) => ExpiresAt <= now; - - public bool IsActive(DateTimeOffset now) => !IsRevoked && !IsExpired(now) && ReplacedByTokenHash is null; -} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenId.cs new file mode 100644 index 00000000..19c704b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenId.cs @@ -0,0 +1,51 @@ +using CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +[JsonConverter(typeof(TokenIdJsonConverter))] +public readonly record struct TokenId(Guid Value) : IParsable +{ + public static TokenId New() => new(Guid.NewGuid()); + + public static TokenId From(Guid value) + => value == Guid.Empty + ? throw new ArgumentException("TokenId cannot be empty.", nameof(value)) + : new TokenId(value); + + public static bool TryCreate(string raw, out TokenId id) + { + if (Guid.TryParse(raw, out var guid) && guid != Guid.Empty) + { + id = new TokenId(guid); + return true; + } + + id = default; + return false; + } + + public static TokenId Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var id)) + return id; + + throw new FormatException("Invalid TokenId."); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out TokenId result) + { + if (!string.IsNullOrWhiteSpace(s) && + Guid.TryParse(s, out var guid) && + guid != Guid.Empty) + { + result = new TokenId(guid); + return true; + } + + result = default; + return false; + } + + public override string ToString() => Value.ToString("N"); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthSessionIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/AuthSessionIdJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/AuthSessionIdJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/AuthSessionIdJsonConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionChainIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionChainIdJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionChainIdJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionChainIdJsonConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionRootIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionRootIdJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/TenantKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/TenantKeyJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TokenIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TokenIdJsonConverter.cs new file mode 100644 index 00000000..25271f91 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TokenIdJsonConverter.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class TokenIdJsonConverter : JsonConverter +{ + public override TokenId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("TokenId must be a string."); + + var raw = reader.GetString(); + + if (!TokenId.TryCreate(raw!, out var id)) + throw new JsonException($"Invalid TokenId value: '{raw}'"); + + return id; + } + + public override void Write(Utf8JsonWriter writer, TokenId value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value.ToString("N")); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UAuthUserIdConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UAuthUserIdConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/UserKeyJsonConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs index 53ca29b6..349dd8ab 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs @@ -5,19 +5,20 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure; public sealed class UAuthRefreshTokenValidator : IRefreshTokenValidator { - private readonly IRefreshTokenStore _store; + private readonly IRefreshTokenStoreFactory _storeFactory; private readonly ITokenHasher _hasher; - public UAuthRefreshTokenValidator(IRefreshTokenStore store, ITokenHasher hasher) + public UAuthRefreshTokenValidator(IRefreshTokenStoreFactory storeFactory, ITokenHasher hasher) { - _store = store; + _storeFactory = storeFactory; _hasher = hasher; } public async Task ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct = default) { + var store = _storeFactory.Create(context.Tenant); var hash = _hasher.Hash(context.RefreshToken); - var stored = await _store.FindByHashAsync(context.Tenant, hash, ct); + var stored = await store.FindByHashAsync(hash, ct); if (stored is null) return RefreshTokenValidationResult.Invalid(); @@ -31,7 +32,7 @@ public async Task ValidateAsync(RefreshTokenValida if (stored.IsExpired(context.Now)) { - await _store.RevokeAsync(context.Tenant, hash, context.Now, null, ct); + await store.RevokeAsync(hash, context.Now, null, ct); return RefreshTokenValidationResult.Invalid(); } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs index b364a558..51be3f4b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs @@ -8,5 +8,5 @@ public interface ICredentialResponseWriter { void Write(HttpContext context, GrantKind kind, AuthSessionId sessionId); void Write(HttpContext context, GrantKind kind, AccessToken accessToken); - void Write(HttpContext context, GrantKind kind, RefreshToken refreshToken); + void Write(HttpContext context, GrantKind kind, RefreshTokenInfo refreshToken); } diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs index 9bb61132..9b525f5f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs @@ -10,5 +10,5 @@ namespace CodeBeam.UltimateAuth.Server.Abstactions; public interface ITokenIssuer { Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken cancellationToken = default); - Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken cancellationToken = default); + Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken cancellationToken = default); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs index 6c43139c..9af510d5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs @@ -33,7 +33,7 @@ public void Write(HttpContext context, GrantKind kind, AuthSessionId sessionId) public void Write(HttpContext context, GrantKind kind, AccessToken token) => WriteInternal(context, kind, token.Token); - public void Write(HttpContext context, GrantKind kind, RefreshToken token) + public void Write(HttpContext context, GrantKind kind, RefreshTokenInfo token) => WriteInternal(context, kind, token.Token); public void WriteInternal(HttpContext context, GrantKind kind, string value) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index dc9ce475..4f1d4909 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -117,6 +117,7 @@ await kernel.ExecuteAsync(async _ => now: now, expiresAt: expiresAt, securityVersion: root.SecurityVersion, + device: context.Device, claims: context.Claims, metadata: context.Metadata ); @@ -193,6 +194,7 @@ await kernel.ExecuteAsync(async _ => now: now, expiresAt: expiresAt, securityVersion: root.SecurityVersion, + device: context.Device, claims: context.Claims, metadata: context.Metadata ); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index 16cd8025..cb0b5955 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -18,15 +18,15 @@ public sealed class UAuthTokenIssuer : ITokenIssuer private readonly IOpaqueTokenGenerator _opaqueGenerator; private readonly IJwtTokenGenerator _jwtGenerator; private readonly ITokenHasher _tokenHasher; - private readonly IRefreshTokenStore _refreshTokenStore; + private readonly IRefreshTokenStoreFactory _storeFactory; private readonly IClock _clock; - public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStore refreshTokenStore, IClock clock) + public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStoreFactory storeFactory, IClock clock) { _opaqueGenerator = opaqueGenerator; _jwtGenerator = jwtGenerator; _tokenHasher = tokenHasher; - _refreshTokenStore = refreshTokenStore; + _storeFactory = storeFactory; _clock = clock; } @@ -50,36 +50,37 @@ UAuthMode.SemiHybrid or }; } - public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) + public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) { if (flow.EffectiveMode == UAuthMode.PureOpaque) return null; - var expires = _clock.UtcNow.Add(flow.OriginalOptions.Token.RefreshTokenLifetime); + if (context.SessionId is not AuthSessionId sessionId) + return null; + + var now = _clock.UtcNow; + var expires = now.Add(flow.OriginalOptions.Token.RefreshTokenLifetime); var raw = _opaqueGenerator.Generate(); var hash = _tokenHasher.Hash(raw); - if (context.SessionId is not AuthSessionId sessionId) - return null; - - var stored = new StoredRefreshToken - { - Tenant = flow.Tenant, - TokenHash = hash, - UserKey = context.UserKey, - SessionId = sessionId, - ChainId = context.ChainId, - IssuedAt = _clock.UtcNow, - ExpiresAt = expires - }; + var stored = RefreshToken.Create( + tokenId: TokenId.New(), + tokenHash: hash, + tenant: flow.Tenant, + userKey: context.UserKey, + sessionId: sessionId, + chainId: context.ChainId, + createdAt: now, + expiresAt: expires); if (persistence == RefreshTokenPersistence.Persist) { - await _refreshTokenStore.StoreAsync(flow.Tenant, stored, ct); + var store = _storeFactory.Create(flow.Tenant); + await store.StoreAsync(stored, ct); } - return new RefreshToken + return new RefreshTokenInfo { Token = raw, TokenHash = hash, diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index 73222294..92f0e6d6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Auth; @@ -10,14 +11,14 @@ namespace CodeBeam.UltimateAuth.Server.Services; public sealed class RefreshTokenRotationService : IRefreshTokenRotationService { private readonly IRefreshTokenValidator _validator; - private readonly IRefreshTokenStore _store; + private readonly IRefreshTokenStoreFactory _storeFactory; private readonly ITokenIssuer _tokenIssuer; private readonly IClock _clock; - public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStore store, ITokenIssuer tokenIssuer, IClock clock) + public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStoreFactory storeFactory, ITokenIssuer tokenIssuer, IClock clock) { _validator = validator; - _store = store; + _storeFactory = storeFactory; _tokenIssuer = tokenIssuer; _clock = clock; } @@ -39,34 +40,30 @@ public async Task RotateAsync(AuthFlowContext flo if (!validation.IsValid) return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; + var store = _storeFactory.Create(validation.Tenant); + if (validation.IsReuseDetected) { if (validation.ChainId is not null) { - await _store.RevokeByChainAsync(validation.Tenant, validation.ChainId.Value, context.Now, ct); + await store.RevokeByChainAsync(validation.ChainId.Value, context.Now, ct); } else if (validation.SessionId is not null) { - await _store.RevokeBySessionAsync(validation.Tenant, validation.SessionId.Value, context.Now, ct); + await store.RevokeBySessionAsync(validation.SessionId.Value, context.Now, ct); } return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; } - if (validation.UserKey is not UserKey uKey) - { - throw new InvalidOperationException("Validated refresh token does not contain a UserKey."); - } + if (validation.UserKey is not UserKey userKey) + throw new UAuthValidationException("Validated refresh token does not contain a UserKey."); if (validation.SessionId is not AuthSessionId sessionId) - { - throw new InvalidOperationException("Validated refresh token does not contain a SessionId."); - } + throw new UAuthValidationException("Validated refresh token does not contain a SessionId."); if (validation.TokenHash == null) - { - throw new InvalidOperationException("Validated refresh token does not contain a hashed token."); - } + throw new UAuthValidationException("Validated refresh token does not contain a hashed token."); var tokenContext = new TokenIssuanceContext { @@ -74,7 +71,7 @@ public async Task RotateAsync(AuthFlowContext flo ? validation.Tenant : TenantKey.Single, - UserKey = uKey, + UserKey = userKey, SessionId = validation.SessionId, ChainId = validation.ChainId }; @@ -89,22 +86,27 @@ public async Task RotateAsync(AuthFlowContext flo }; // Never issue new refresh token before revoke old. Upperline doesn't persist token currently. - // TODO: Add _store.ExecuteAsync here to wrap RevokeAsync and StoreAsync - await _store.RevokeAsync(validation.Tenant, validation.TokenHash, context.Now, refreshToken.TokenHash, ct); - - var stored = new StoredRefreshToken + await store.ExecuteAsync(async ct2 => { - Tenant = flow.Tenant, - TokenHash = refreshToken.TokenHash, - UserKey = uKey, - SessionId = sessionId, - ChainId = validation.ChainId, - IssuedAt = _clock.UtcNow, - ExpiresAt = refreshToken.ExpiresAt - }; - await _store.StoreAsync(validation.Tenant, stored); + await store.RevokeAsync(validation.TokenHash, context.Now, refreshToken.TokenHash, ct2); + + var stored = RefreshToken.Create( + tokenId: TokenId.New(), + tokenHash: refreshToken.TokenHash, + tenant: validation.Tenant, + userKey: userKey, + sessionId: sessionId, + chainId: validation.ChainId, + createdAt: _clock.UtcNow, + expiresAt: refreshToken.ExpiresAt + ); + + await store.StoreAsync(stored, ct2); + + }, ct); + - return new RefreshTokenRotationExecution() + return new RefreshTokenRotationExecution { Tenant = validation.Tenant, UserKey = validation.UserKey, diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.csproj b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.csproj new file mode 100644 index 00000000..4c065564 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions.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/Infrastructure/AuthSessionIdEfConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/AuthSessionIdEfConverter.cs similarity index 84% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/AuthSessionIdEfConverter.cs index 60c6a5b1..dd6adbd5 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/AuthSessionIdEfConverter.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; internal static class AuthSessionIdEfConverter { @@ -14,8 +14,7 @@ public static AuthSessionId FromDatabase(string raw) return id; } - public static string ToDatabase(AuthSessionId id) - => id.Value; + public static string ToDatabase(AuthSessionId id) => id.Value; public static AuthSessionId? FromDatabaseNullable(string? raw) { diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueConverter.cs similarity index 69% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueConverter.cs index 68fb5ff1..6669f323 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueConverter.cs @@ -1,9 +1,9 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using System.Text.Json; -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; -internal sealed class JsonValueConverter : ValueConverter +public sealed class JsonValueConverter : ValueConverter { public JsonValueConverter() : base( diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableAuthSessionIdConverter.cs similarity index 64% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs rename to src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableAuthSessionIdConverter.cs index 099a69cc..7972f21a 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableAuthSessionIdConverter.cs @@ -1,16 +1,16 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; -internal sealed class AuthSessionIdConverter : ValueConverter +public sealed class AuthSessionIdConverter : ValueConverter { public AuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabase(id), raw => AuthSessionIdEfConverter.FromDatabase(raw)) { } } -internal sealed class NullableAuthSessionIdConverter : ValueConverter +public sealed class NullableAuthSessionIdConverter : ValueConverter { public NullableAuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabaseNullable(id), raw => AuthSessionIdEfConverter.FromDatabaseNullable(raw)) { diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableSessionChainIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableSessionChainIdConverter.cs new file mode 100644 index 00000000..34e22b2e --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableSessionChainIdConverter.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +public sealed class NullableSessionChainIdConverter : ValueConverter +{ + public NullableSessionChainIdConverter() + : base( + id => SessionChainIdEfConverter.ToDatabaseNullable(id), + raw => SessionChainIdEfConverter.FromDatabaseNullable(raw)) + { + } +} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdConverter.cs new file mode 100644 index 00000000..ff21b301 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdConverter.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +public sealed class SessionChainIdConverter : ValueConverter +{ + public SessionChainIdConverter() + : base( + id => SessionChainIdEfConverter.ToDatabase(id), + raw => SessionChainIdEfConverter.FromDatabase(raw)) + { + } +} \ No newline at end of file diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdEfConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdEfConverter.cs new file mode 100644 index 00000000..14dc9eae --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/SessionChainIdEfConverter.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +internal static class SessionChainIdEfConverter +{ + public static SessionChainId FromDatabase(Guid raw) + { + return SessionChainId.From(raw); + } + + public static Guid ToDatabase(SessionChainId id) => id.Value; + + public static SessionChainId? FromDatabaseNullable(Guid? raw) + { + if (raw is null) + return null; + + return SessionChainId.From(raw.Value); + } + + public static Guid? ToDatabaseNullable(SessionChainId? id) => id?.Value; +} 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 index e656299b..4cda4e65 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj @@ -8,21 +8,9 @@ $(NoWarn);1591 - - - - - - - - - - - - - + diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs index b2dce495..c78491f8 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs index 0c027d8f..6377ea28 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -15,6 +15,7 @@ public static UAuthSession ToDomain(this SessionProjection p) p.ExpiresAt, p.RevokedAt, p.SecurityVersionAtCreation, + p.Device, p.Claims, p.Metadata, p.Version @@ -35,6 +36,7 @@ public static SessionProjection ToProjection(this UAuthSession s) RevokedAt = s.RevokedAt, SecurityVersionAtCreation = s.SecurityVersionAtCreation, + Device = s.Device, Claims = s.Claims, Metadata = s.Metadata, Version = s.Version diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs similarity index 94% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs index 6da8b412..ffc23f10 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs @@ -16,7 +16,7 @@ internal sealed class SessionChainProjection public DateTimeOffset LastSeenAt { get; set; } public DateTimeOffset? AbsoluteExpiresAt { get; set; } public DeviceId DeviceId { get; set; } - public DeviceContext Device { get; set; } + public DeviceContext Device { get; set; } = DeviceContext.Anonymous(); public ClaimsSnapshot ClaimsSnapshot { get; set; } = ClaimsSnapshot.Empty; public AuthSessionId? ActiveSessionId { get; set; } public int RotationCount { get; set; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs similarity index 100% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs similarity index 100% rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs 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 index 9f31a13b..29b9bf38 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj @@ -7,22 +7,10 @@ true $(NoWarn);1591 - - - - - - - - - - - - - + diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs similarity index 50% rename from src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs rename to src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs index f8c371ea..a7caf53b 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/UAuthTokenDbContext.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs @@ -1,4 +1,7 @@ -using Microsoft.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; @@ -18,42 +21,44 @@ protected override void OnModelCreating(ModelBuilder b) { e.HasKey(x => x.Id); - e.Property(x => x.Version).IsConcurrencyToken(); + e.Property(x => x.Version) + .IsConcurrencyToken(); - e.Property(x => x.TokenHash) + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.TokenId) + .HasConversion( + v => v.Value, + v => TokenId.From(v)) .IsRequired(); - e.HasIndex(x => new { x.Tenant, x.TokenHash }) - .IsUnique(); + e.Property(x => x.TokenHash) + .HasMaxLength(128) + .IsRequired(); + e.HasIndex(x => new { x.Tenant, x.TokenHash }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.TokenHash, x.RevokedAt }); + e.HasIndex(x => new { x.Tenant, x.TokenId }); e.HasIndex(x => new { x.Tenant, x.UserKey }); e.HasIndex(x => new { x.Tenant, x.SessionId }); e.HasIndex(x => new { x.Tenant, x.ChainId }); e.HasIndex(x => new { x.Tenant, x.ExpiresAt }); + e.HasIndex(x => new { x.Tenant, x.ExpiresAt, x.RevokedAt }); e.HasIndex(x => new { x.Tenant, x.ReplacedByTokenHash }); - e.Property(x => x.ExpiresAt).IsRequired(); - }); + e.Property(x => x.SessionId) + .HasConversion(new AuthSessionIdConverter()); - b.Entity(e => - { - e.HasKey(x => x.Id); - - e.Property(x => x.Version).IsConcurrencyToken(); - - e.Property(x => x.Jti) - .IsRequired(); - - e.HasIndex(x => x.Jti) - .IsUnique(); - - e.HasIndex(x => new { x.Tenant, x.Jti }); + e.Property(x => x.ChainId) + .HasConversion(new NullableSessionChainIdConverter()); e.Property(x => x.ExpiresAt) .IsRequired(); - - e.Property(x => x.RevokedAt) - .IsRequired(); }); } } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs deleted file mode 100644 index f4f95708..00000000 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/EfCoreTokenStore.cs +++ /dev/null @@ -1,110 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using Microsoft.EntityFrameworkCore; - -namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; - -internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore -{ - private readonly UltimateAuthTokenDbContext _db; - - public EfCoreRefreshTokenStore(UltimateAuthTokenDbContext db, IUserIdConverterResolver converters) - { - _db = db; - } - - public async Task StoreAsync(TenantKey tenantId, StoredRefreshToken token, CancellationToken ct = default) - { - if (token.Tenant != tenantId) - throw new InvalidOperationException("TenantId mismatch between context and token."); - - if (token.ChainId is null) - throw new InvalidOperationException("Refresh token must have a ChainId before being stored."); - - _db.RefreshTokens.Add(new RefreshTokenProjection - { - Tenant = tenantId, - TokenHash = token.TokenHash, - UserKey = token.UserKey, - SessionId = token.SessionId, - ChainId = token.ChainId.Value, - IssuedAt = token.IssuedAt, - ExpiresAt = token.ExpiresAt - }); - - await _db.SaveChangesAsync(ct); - } - - public async Task FindByHashAsync(TenantKey tenant, string tokenHash, CancellationToken ct = default) - { - var e = await _db.RefreshTokens - .AsNoTracking() - .SingleOrDefaultAsync( - x => x.TokenHash == tokenHash && - x.Tenant == tenant, - ct); - - if (e is null) - return null; - - return new StoredRefreshToken - { - Tenant = e.Tenant, - TokenHash = e.TokenHash, - UserKey = e.UserKey, - SessionId = e.SessionId, - ChainId = e.ChainId, - IssuedAt = e.IssuedAt, - ExpiresAt = e.ExpiresAt, - RevokedAt = e.RevokedAt - }; - } - - public Task RevokeAsync(TenantKey tenant, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) - { - var query = _db.RefreshTokens - .Where(x => - x.TokenHash == tokenHash && - x.Tenant == tenant && - x.RevokedAt == null); - - if (replacedByTokenHash == null) - { - return query.ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - } - - return query.ExecuteUpdateAsync( - x => x - .SetProperty(t => t.RevokedAt, revokedAt) - .SetProperty(t => t.ReplacedByTokenHash, replacedByTokenHash), - ct); - } - - public Task RevokeBySessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) - => _db.RefreshTokens - .Where(x => - x.Tenant == tenant && - x.SessionId == sessionId.Value && - x.RevokedAt == null) - .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - - public Task RevokeByChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) - => _db.RefreshTokens - .Where(x => - x.Tenant == tenant && - x.ChainId == chainId && - x.RevokedAt == null) - .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - - public Task RevokeAllForUserAsync(TenantKey tenant, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) - { - - return _db.RefreshTokens - .Where(x => - x.Tenant == tenant && - x.UserKey == userKey && - x.RevokedAt == null) - .ExecuteUpdateAsync(x => x.SetProperty(t => t.RevokedAt, revokedAt), ct); - } -} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs similarity index 72% rename from src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs rename to src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 243d161c..c3dcc34f 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -8,8 +8,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthEntityFrameworkCoreTokens(this IServiceCollection services, Action configureDb) { - services.AddDbContext(configureDb); - services.AddScoped(typeof(IRefreshTokenStore), typeof(EfCoreRefreshTokenStore)); + services.AddDbContextPool(configureDb); + services.AddScoped(); return services; } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Mappers/RefreshTokenProjectionMapper.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Mappers/RefreshTokenProjectionMapper.cs new file mode 100644 index 00000000..d7c2e41c --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Mappers/RefreshTokenProjectionMapper.cs @@ -0,0 +1,43 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal static class RefreshTokenProjectionMapper +{ + public static RefreshToken ToDomain(this RefreshTokenProjection p) + { + return RefreshToken.Create( + tokenId: p.TokenId, + tokenHash: p.TokenHash, + tenant: p.Tenant, + userKey: p.UserKey, + sessionId: p.SessionId, + chainId: p.ChainId, + createdAt: p.CreatedAt, + expiresAt: p.ExpiresAt + ) with + { + RevokedAt = p.RevokedAt, + ReplacedByTokenHash = p.ReplacedByTokenHash, + Version = p.Version + }; + } + + public static RefreshTokenProjection ToProjection(this RefreshToken t) + { + return new RefreshTokenProjection + { + TokenId = t.TokenId, + Tenant = t.Tenant, + TokenHash = t.TokenHash, + UserKey = t.UserKey, + SessionId = t.SessionId, + ChainId = t.ChainId, + CreatedAt = t.CreatedAt, + ExpiresAt = t.ExpiresAt, + RevokedAt = t.RevokedAt, + ReplacedByTokenHash = t.ReplacedByTokenHash, + Version = t.Version + }; + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs index d1167f36..3f47970e 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs @@ -3,21 +3,28 @@ 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 long Id { get; set; } // EF PK + + public TokenId TokenId { get; set; } + public TenantKey Tenant { get; set; } public string TokenHash { get; set; } = default!; + public UserKey UserKey { get; set; } = default!; + public AuthSessionId SessionId { get; set; } = default!; - public SessionChainId ChainId { get; set; } = default!; + + public SessionChainId? ChainId { get; set; } public string? ReplacedByTokenHash { get; set; } - public DateTimeOffset IssuedAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } public long Version { get; set; } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs new file mode 100644 index 00000000..7347936e --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs @@ -0,0 +1,162 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore +{ + private readonly UltimateAuthTokenDbContext _db; + private readonly TenantKey _tenant; + + public EfCoreRefreshTokenStore(UltimateAuthTokenDbContext db, TenantKey tenant) + { + _db = db; + _tenant = tenant; + } + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + await strategy.ExecuteAsync(async () => + { + await using var tx = + await _db.Database.BeginTransactionAsync(ct); + + try + { + await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + }); + } + + public async Task ExecuteAsync(Func> action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var tx = + await _db.Database.BeginTransactionAsync(ct); + + try + { + var result = await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return result; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + }); + } + + public Task StoreAsync(RefreshToken token, CancellationToken ct = default) + { + if (token.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + + var projection = token.ToProjection(); + + _db.RefreshTokens.Add(projection); + + return Task.CompletedTask; + } + + public async Task FindByHashAsync( + string tokenHash, + CancellationToken ct = default) + { + var p = await _db.RefreshTokens + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.Tenant == _tenant && + x.TokenHash == tokenHash, + ct); + + return p?.ToDomain(); + } + + public Task RevokeAsync( + string tokenHash, + DateTimeOffset revokedAt, + string? replacedByTokenHash = null, + CancellationToken ct = default) + { + var query = _db.RefreshTokens + .Where(x => + x.Tenant == _tenant && + x.TokenHash == tokenHash && + x.RevokedAt == null); + + if (replacedByTokenHash is null) + { + return query.ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); + } + + return query.ExecuteUpdateAsync( + x => x + .SetProperty(t => t.RevokedAt, revokedAt) + .SetProperty(t => t.ReplacedByTokenHash, replacedByTokenHash), + ct); + } + + public Task RevokeBySessionAsync( + AuthSessionId sessionId, + DateTimeOffset revokedAt, + CancellationToken ct = default) + { + return _db.RefreshTokens + .Where(x => + x.Tenant == _tenant && + x.SessionId == sessionId && + x.RevokedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); + } + + public Task RevokeByChainAsync( + SessionChainId chainId, + DateTimeOffset revokedAt, + CancellationToken ct = default) + { + return _db.RefreshTokens + .Where(x => + x.Tenant == _tenant && + x.ChainId == chainId && + x.RevokedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); + } + + public Task RevokeAllForUserAsync( + UserKey userKey, + DateTimeOffset revokedAt, + CancellationToken ct = default) + { + return _db.RefreshTokens + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RevokedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs new file mode 100644 index 00000000..e329766d --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +public sealed class EfCoreRefreshTokenStoreFactory : IRefreshTokenStoreFactory +{ + private readonly IServiceProvider _sp; + + public EfCoreRefreshTokenStoreFactory(IServiceProvider sp) + { + _sp = sp; + } + + public IRefreshTokenStore Create(TenantKey tenant) + { + return ActivatorUtilities.CreateInstance(_sp, tenant); + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs index 7046a019..683cb61b 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs @@ -5,88 +5,121 @@ namespace CodeBeam.UltimateAuth.Tokens.InMemory; -public sealed class InMemoryRefreshTokenStore : IRefreshTokenStore +internal sealed class InMemoryRefreshTokenStore : IRefreshTokenStore { - private static string NormalizeTenant(string? tenantId) => tenantId ?? "__single__"; + private readonly TenantKey _tenant; + private readonly SemaphoreSlim _tx = new(1, 1); - private readonly ConcurrentDictionary _tokens = new(); + private readonly ConcurrentDictionary _tokens = new(); - public Task StoreAsync(TenantKey tenant, StoredRefreshToken token, CancellationToken ct = default) + public InMemoryRefreshTokenStore(TenantKey tenant) { - var key = new TokenKey(NormalizeTenant(tenant), token.TokenHash); + _tenant = tenant; + } + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + await _tx.WaitAsync(ct); + + try + { + await action(ct); + } + finally + { + _tx.Release(); + } + } + + public async Task ExecuteAsync(Func> action, CancellationToken ct = default) + { + await _tx.WaitAsync(ct); + + try + { + return await action(ct); + } + finally + { + _tx.Release(); + } + } + + public Task StoreAsync(RefreshToken token, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (token.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + + _tokens[token.TokenHash] = token; - _tokens[key] = token; return Task.CompletedTask; } - public Task FindByHashAsync(TenantKey tenant, string tokenHash, CancellationToken ct = default) + public Task FindByHashAsync(string tokenHash, CancellationToken ct = default) { - var key = new TokenKey(NormalizeTenant(tenant), tokenHash); + ct.ThrowIfCancellationRequested(); + + _tokens.TryGetValue(tokenHash, out var token); - _tokens.TryGetValue(key, out var token); return Task.FromResult(token); } - public Task RevokeAsync(TenantKey tenant, string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) + public Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) { - var key = new TokenKey(NormalizeTenant(tenant), tokenHash); + ct.ThrowIfCancellationRequested(); - if (_tokens.TryGetValue(key, out var token) && !token.IsRevoked) + if (_tokens.TryGetValue(tokenHash, out var token) && !token.IsRevoked) { - _tokens[key] = token with - { - RevokedAt = revokedAt, - ReplacedByTokenHash = replacedByTokenHash - }; + _tokens[tokenHash] = token.Revoke(revokedAt, replacedByTokenHash); } return Task.CompletedTask; } - public Task RevokeBySessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeBySessionAsync(AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) { - foreach (var (key, token) in _tokens) + ct.ThrowIfCancellationRequested(); + + foreach (var (hash, token) in _tokens.ToArray()) { - if (key.TenantId == tenant && - token.SessionId == sessionId && - !token.IsRevoked) + if (token.SessionId == sessionId && !token.IsRevoked) { - _tokens[key] = token with { RevokedAt = revokedAt }; + _tokens[hash] = token.Revoke(revokedAt); } } return Task.CompletedTask; } - public Task RevokeByChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeByChainAsync(SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) { - foreach (var (key, token) in _tokens) + ct.ThrowIfCancellationRequested(); + + foreach (var (hash, token) in _tokens.ToArray()) { - if (key.TenantId == tenant && - token.ChainId == chainId && - !token.IsRevoked) + if (token.ChainId == chainId && !token.IsRevoked) { - _tokens[key] = token with { RevokedAt = revokedAt }; + _tokens[hash] = token.Revoke(revokedAt); } } return Task.CompletedTask; } - public Task RevokeAllForUserAsync(TenantKey tenant, UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) + public Task RevokeAllForUserAsync(UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) { - foreach (var (key, token) in _tokens) + ct.ThrowIfCancellationRequested(); + + foreach (var (hash, token) in _tokens.ToArray()) { - if (key.TenantId == tenant && - token.UserKey == userKey && - !token.IsRevoked) + if (token.UserKey == userKey && !token.IsRevoked) { - _tokens[key] = token with { RevokedAt = revokedAt }; + _tokens[hash] = token.Revoke(revokedAt); } } return Task.CompletedTask; } - - private readonly record struct TokenKey(string TenantId, string TokenHash); -} +} \ No newline at end of file diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStoreFactory.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStoreFactory.cs new file mode 100644 index 00000000..e749cc38 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStoreFactory.cs @@ -0,0 +1,15 @@ +using System.Collections.Concurrent; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tokens.InMemory; + +public sealed class InMemoryRefreshTokenStoreFactory : IRefreshTokenStoreFactory +{ + private readonly ConcurrentDictionary _stores = new(); + + public IRefreshTokenStore Create(TenantKey tenant) + { + return _stores.GetOrAdd(tenant, _ => new InMemoryRefreshTokenStore(tenant)); + } +} \ No newline at end of file diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs index 4716253e..76b12711 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs @@ -7,7 +7,7 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthInMemoryTokens(this IServiceCollection services) { - services.AddSingleton(typeof(IRefreshTokenStore), typeof(InMemoryRefreshTokenStore)); + services.AddSingleton(); return services; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs index 3e967fce..18f513ac 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs @@ -14,9 +14,9 @@ public sealed class RefreshTokenValidatorTests { private const string ValidDeviceId = "deviceidshouldbelongandstrongenough!?1234567890"; - private static UAuthRefreshTokenValidator CreateValidator(InMemoryRefreshTokenStore store) + private static UAuthRefreshTokenValidator CreateValidator(InMemoryRefreshTokenStoreFactory factory) { - return new UAuthRefreshTokenValidator(store, CreateHasher()); + return new UAuthRefreshTokenValidator(factory, CreateHasher()); } private static ITokenHasher CreateHasher() @@ -27,8 +27,8 @@ private static ITokenHasher CreateHasher() [Fact] public async Task Invalid_When_Token_Not_Found() { - var store = new InMemoryRefreshTokenStore(); - var validator = CreateValidator(store); + var factory = new InMemoryRefreshTokenStoreFactory(); + var validator = CreateValidator(factory); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -46,26 +46,30 @@ public async Task Invalid_When_Token_Not_Found() [Fact] public async Task Reuse_Detected_When_Token_is_Revoked() { - var store = new InMemoryRefreshTokenStore(); + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + var hasher = CreateHasher(); - var validator = CreateValidator(store); + var validator = CreateValidator(factory); var now = DateTimeOffset.UtcNow; var rawToken = "refresh-token-1"; var hash = hasher.Hash(rawToken); - await store.StoreAsync(TenantKey.Single, new StoredRefreshToken - { - Tenant = TenantKey.Single, - TokenHash = hash, - UserKey = UserKey.FromString("user-1"), - SessionId = TestIds.Session("session-1-aaaaaaaaaaaaaaaaaaaaaa"), - ChainId = SessionChainId.New(), - IssuedAt = now.AddMinutes(-5), - ExpiresAt = now.AddMinutes(5), - RevokedAt = now - }); + var token = RefreshToken.Create( + TokenId.New(), + hash, + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-1-aaaaaaaaaaaaaaaaaaaaaa"), + SessionChainId.New(), + now.AddMinutes(-5), + now.AddMinutes(5)); + + var revoked = token.Revoke(now); + + await store.StoreAsync(revoked); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -83,21 +87,24 @@ public async Task Reuse_Detected_When_Token_is_Revoked() [Fact] public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() { - var store = new InMemoryRefreshTokenStore(); - var validator = CreateValidator(store); + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + + var validator = CreateValidator(factory); var now = DateTimeOffset.UtcNow; - await store.StoreAsync(TenantKey.Single, new StoredRefreshToken - { - Tenant = TenantKey.Single, - TokenHash = "hash-2", - UserKey = UserKey.FromString("user-1"), - SessionId = TestIds.Session("session-1-bbbbbbbbbbbbbbbbbbbbbb"), - ChainId = SessionChainId.New(), - IssuedAt = now, - ExpiresAt = now.AddMinutes(10) - }); + var token = RefreshToken.Create( + TokenId.New(), + "hash-2", + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-1-bbbbbbbbbbbbbbbbbbbbbb"), + SessionChainId.New(), + now, + now.AddMinutes(10)); + + await store.StoreAsync(token); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -113,5 +120,114 @@ public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() Assert.False(result.IsReuseDetected); } -} + [Fact] + public async Task Invalid_When_Token_Is_Expired() + { + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + + var validator = CreateValidator(factory); + + var now = DateTimeOffset.UtcNow; + + var token = RefreshToken.Create( + TokenId.New(), + "expired-hash", + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-expired"), + SessionChainId.New(), + now.AddMinutes(-10), + now.AddMinutes(-1)); + + await store.StoreAsync(token); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = TenantKey.Single, + RefreshToken = "expired-hash", + Now = now, + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + }); + + Assert.False(result.IsValid); + Assert.False(result.IsReuseDetected); + } + + [Fact] + public async Task Valid_When_Token_Is_Active() + { + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + + var validator = CreateValidator(factory); + + var now = DateTimeOffset.UtcNow; + + var raw = "valid-token"; + var hash = CreateHasher().Hash(raw); + + var token = RefreshToken.Create( + TokenId.New(), + hash, + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-valid"), + SessionChainId.New(), + now, + now.AddMinutes(10)); + await store.StoreAsync(token); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = TenantKey.Single, + RefreshToken = raw, + Now = now, + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + }); + + Assert.True(result.IsValid); + Assert.False(result.IsReuseDetected); + } + + [Fact] + public async Task Reuse_Detected_When_Old_Token_Is_Reused_After_Rotation() + { + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + + var validator = CreateValidator(factory); + + var now = DateTimeOffset.UtcNow; + + var raw = "token-1"; + var hash = CreateHasher().Hash(raw); + + var token = RefreshToken.Create( + TokenId.New(), + hash, + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-rotate"), + SessionChainId.New(), + now, + now.AddMinutes(10)); + + await store.StoreAsync(token.Revoke(now, "new-hash")); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = TenantKey.Single, + RefreshToken = raw, + Now = now, + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + }); + + Assert.False(result.IsValid); + Assert.True(result.IsReuseDetected); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs index e988dc1d..c5a3cfb7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs @@ -22,6 +22,7 @@ public void Revoke_marks_session_as_revoked() now, now.AddMinutes(10), 0, + DeviceContext.Create(DeviceId.Create(ValidDeviceId)), ClaimsSnapshot.Empty, SessionMetadata.Empty); @@ -46,6 +47,7 @@ public void Revoking_twice_returns_same_instance() now, now.AddMinutes(10), 0, + DeviceContext.Create(DeviceId.Create(ValidDeviceId)), ClaimsSnapshot.Empty, SessionMetadata.Empty); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs index fca5e3a5..1e4ad3d5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs @@ -6,6 +6,11 @@ internal static class TestIds { public static AuthSessionId Session(string raw) { + if (raw.Length < 32) + { + raw = raw.PadRight(32, 'x'); + } + if (!AuthSessionId.TryCreate(raw, out var id)) throw new InvalidOperationException($"Invalid test AuthSessionId: {raw}");