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