diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx
index 22c26b0d..f7eed3a7 100644
--- a/UltimateAuth.slnx
+++ b/UltimateAuth.slnx
@@ -26,6 +26,7 @@
+
@@ -33,6 +34,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..2f9b3da7 100644
--- a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs
+++ b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs
@@ -2,4 +2,8 @@
[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Server")]
[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore")]
+[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore")]
+[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Users.EntityFrameworkCore")]
+[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore")]
+[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore")]
[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")]
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/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 806a9c5b..b9351524 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs
@@ -1,9 +1,9 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Errors;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
namespace CodeBeam.UltimateAuth.Core.Domain;
-// TODO: Add ISoftDeleteable
public sealed class UAuthSession : IVersionedEntity
{
public AuthSessionId SessionId { get; }
@@ -12,13 +12,15 @@ 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 DeviceContext Device { get; } // For snapshot,main value is chain's device.
public ClaimsSnapshot Claims { get; }
public SessionMetadata Metadata { get; }
public long Version { get; set; }
+ public bool IsRevoked => RevokedAt != null;
+
private UAuthSession(
AuthSessionId sessionId,
TenantKey tenant,
@@ -26,9 +28,9 @@ private UAuthSession(
SessionChainId chainId,
DateTimeOffset createdAt,
DateTimeOffset expiresAt,
- bool isRevoked,
DateTimeOffset? revokedAt,
long securityVersionAtCreation,
+ DeviceContext device,
ClaimsSnapshot claims,
SessionMetadata metadata,
long version)
@@ -39,9 +41,9 @@ private UAuthSession(
ChainId = chainId;
CreatedAt = createdAt;
ExpiresAt = expiresAt;
- IsRevoked = isRevoked;
RevokedAt = revokedAt;
SecurityVersionAtCreation = securityVersionAtCreation;
+ Device = device;
Claims = claims;
Metadata = metadata;
Version = version;
@@ -55,6 +57,7 @@ public static UAuthSession Create(
DateTimeOffset now,
DateTimeOffset expiresAt,
long securityVersion,
+ DeviceContext device,
ClaimsSnapshot? claims,
SessionMetadata metadata)
{
@@ -65,9 +68,9 @@ public static UAuthSession Create(
chainId,
createdAt: now,
expiresAt: expiresAt,
- isRevoked: false,
revokedAt: null,
securityVersionAtCreation: securityVersion,
+ device: device,
claims: claims ?? ClaimsSnapshot.Empty,
metadata: metadata,
version: 0
@@ -76,7 +79,8 @@ public static UAuthSession Create(
public UAuthSession Revoke(DateTimeOffset at)
{
- if (IsRevoked) return this;
+ if (IsRevoked)
+ return this;
return new UAuthSession(
SessionId,
@@ -85,9 +89,9 @@ public UAuthSession Revoke(DateTimeOffset at)
ChainId,
CreatedAt,
ExpiresAt,
- true,
at,
SecurityVersionAtCreation,
+ Device,
Claims,
Metadata,
Version + 1
@@ -101,9 +105,9 @@ internal static UAuthSession FromProjection(
SessionChainId chainId,
DateTimeOffset createdAt,
DateTimeOffset expiresAt,
- bool isRevoked,
DateTimeOffset? revokedAt,
long securityVersionAtCreation,
+ DeviceContext device,
ClaimsSnapshot claims,
SessionMetadata metadata,
long version)
@@ -115,9 +119,9 @@ internal static UAuthSession FromProjection(
chainId,
createdAt,
expiresAt,
- isRevoked,
revokedAt,
securityVersionAtCreation,
+ device,
claims,
metadata,
version
@@ -138,7 +142,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,
@@ -147,9 +151,9 @@ public UAuthSession WithChain(SessionChainId chainId)
chainId: chainId,
createdAt: CreatedAt,
expiresAt: ExpiresAt,
- isRevoked: IsRevoked,
revokedAt: RevokedAt,
securityVersionAtCreation: SecurityVersionAtCreation,
+ device: Device,
claims: Claims,
metadata: Metadata,
version: Version + 1
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/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/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
index 694def16..e3b1ba17 100644
--- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
@@ -21,8 +21,7 @@ namespace CodeBeam.UltimateAuth.Server.Flows;
internal sealed class LoginOrchestrator : ILoginOrchestrator
{
private readonly ILoginIdentifierResolver _identifierResolver;
- private readonly ICredentialStore _credentialStore; // authentication
- private readonly ICredentialValidator _credentialValidator;
+ private readonly IEnumerable _credentialProviders; // authentication
private readonly IUserRuntimeStateProvider _users; // eligible
private readonly ILoginAuthority _authority;
private readonly ISessionOrchestrator _sessionOrchestrator;
@@ -35,8 +34,7 @@ internal sealed class LoginOrchestrator : ILoginOrchestrator
public LoginOrchestrator(
ILoginIdentifierResolver identifierResolver,
- ICredentialStore credentialStore,
- ICredentialValidator credentialValidator,
+ IEnumerable credentialProviders,
IUserRuntimeStateProvider users,
ILoginAuthority authority,
ISessionOrchestrator sessionOrchestrator,
@@ -48,8 +46,7 @@ public LoginOrchestrator(
IOptions options)
{
_identifierResolver = identifierResolver;
- _credentialStore = credentialStore;
- _credentialValidator = credentialValidator;
+ _credentialProviders = credentialProviders;
_users = users;
_authority = authority;
_sessionOrchestrator = sessionOrchestrator;
@@ -99,21 +96,24 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
return LoginResult.Failed(AuthFailureReason.LockedOut, factorState.LockedUntil, 0);
}
- var credentials = await _credentialStore.GetByUserAsync(request.Tenant, userKey.Value, ct);
-
- // TODO: Add .Where(c => c.Type == request.Factor) when we support multiple factors per user
- foreach (var credential in credentials.OfType())
+ foreach (var provider in _credentialProviders)
{
- if (!credential.Security.IsUsable(now))
- continue;
-
- var result = await _credentialValidator.ValidateAsync((ICredential)credential, request.Secret, ct);
+ var credentials = await provider.GetByUserAsync(request.Tenant, userKey.Value, ct);
- if (result.IsValid)
+ foreach (var credential in credentials)
{
- credentialsValid = true;
- break;
+ if (credential.IsDeleted || !credential.Security.IsUsable(now))
+ continue;
+
+ if (await provider.ValidateAsync(credential, request.Secret, ct))
+ {
+ credentialsValid = true;
+ break;
+ }
}
+
+ if (credentialsValid)
+ break;
}
}
}
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/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;
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs
index 27436d4a..04edc296 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs
@@ -9,7 +9,7 @@ public sealed class CredentialSecurityState
public Guid SecurityStamp { get; }
public bool IsRevoked => RevokedAt != null;
- public bool IsExpired => ExpiresAt != null;
+ public bool IsExpired(DateTimeOffset now) => ExpiresAt != null && ExpiresAt <= now;
public CredentialSecurityState(
DateTimeOffset? revokedAt = null,
@@ -26,7 +26,7 @@ public CredentialSecurityStatus Status(DateTimeOffset now)
if (RevokedAt is not null)
return CredentialSecurityStatus.Revoked;
- if (ExpiresAt is not null && ExpiresAt <= now)
+ if (IsExpired(now))
return CredentialSecurityStatus.Expired;
return CredentialSecurityStatus.Active;
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs
deleted file mode 100644
index 5b8773ff..00000000
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Abstractions/CredentialUserMapping.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-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/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj
index 59c525b9..7cedc479 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj
@@ -25,6 +25,7 @@
+
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs
deleted file mode 100644
index 08378ed6..00000000
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/ConventionResolver.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-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
deleted file mode 100644
index 4baa14c7..00000000
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingBuilder.cs
+++ /dev/null
@@ -1,74 +0,0 @@
-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
deleted file mode 100644
index b8c326fc..00000000
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Configuration/CredentialUserMappingOptions.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-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/Data/UAuthCredentialDbContext.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs
new file mode 100644
index 00000000..ed530b36
--- /dev/null
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs
@@ -0,0 +1,70 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Credentials.Contracts;
+using CodeBeam.UltimateAuth.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore;
+
+internal sealed class UAuthCredentialDbContext : DbContext
+{
+ public DbSet PasswordCredentials => Set();
+
+ private readonly TenantContext _tenant;
+
+ public UAuthCredentialDbContext(DbContextOptions options, TenantContext tenant)
+ : base(options)
+ {
+ _tenant = tenant;
+ }
+
+ protected override void OnModelCreating(ModelBuilder b)
+ {
+ ConfigurePasswordCredential(b);
+ }
+
+ private void ConfigurePasswordCredential(ModelBuilder b)
+ {
+ b.Entity(e =>
+ {
+ e.HasKey(x => x.Id);
+
+ e.Property(x => x.Version).IsConcurrencyToken();
+
+ e.Property(x => x.Tenant)
+ .HasConversion(
+ v => v.Value,
+ v => TenantKey.FromInternal(v))
+ .HasMaxLength(128)
+ .IsRequired();
+
+ e.Property(x => x.UserKey)
+ .HasConversion(
+ v => v.Value,
+ v => UserKey.FromString(v))
+ .HasMaxLength(128)
+ .IsRequired();
+
+ e.Property(x => x.SecretHash)
+ .HasMaxLength(512)
+ .IsRequired();
+
+ e.Property(x => x.SecurityStamp).IsRequired();
+ e.Property(x => x.RevokedAt);
+ e.Property(x => x.ExpiresAt);
+ e.Property(x => x.LastUsedAt);
+ e.Property(x => x.Source).HasMaxLength(128);
+ e.Property(x => x.CreatedAt).IsRequired();
+ e.Property(x => x.UpdatedAt);
+ e.Property(x => x.DeletedAt);
+
+ e.HasIndex(x => new { x.Tenant, x.Id }).IsUnique();
+ e.HasIndex(x => new { x.Tenant, x.UserKey });
+ e.HasIndex(x => new { x.Tenant, x.UserKey, x.DeletedAt });
+ e.HasIndex(x => new { x.Tenant, x.RevokedAt });
+ e.HasIndex(x => new { x.Tenant, x.ExpiresAt });
+
+ e.HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant);
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs
deleted file mode 100644
index 61a8b904..00000000
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/EfCoreAuthUser.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Domain;
-
-namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore;
-
-internal sealed class EfCoreAuthUser : IAuthSubject
-{
- public TUserId UserId { get; }
-
- IReadOnlyDictionary? IAuthSubject.Claims => null;
-
- public EfCoreAuthUser(TUserId userId)
- {
- UserId = userId;
- }
-}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 00000000..e235a2a1
--- /dev/null
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,15 @@
+using CodeBeam.UltimateAuth.Credentials.Reference;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddUltimateAuthEntityFrameworkCoreCredentials(this IServiceCollection services, Action configureDb)
+ {
+ services.AddDbContextPool(configureDb);
+ services.AddScoped();
+ return services;
+ }
+}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Mappers/PasswordCredentialProjectionMapper.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Mappers/PasswordCredentialProjectionMapper.cs
new file mode 100644
index 00000000..8fbf3854
--- /dev/null
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Mappers/PasswordCredentialProjectionMapper.cs
@@ -0,0 +1,67 @@
+using CodeBeam.UltimateAuth.Credentials.Contracts;
+using CodeBeam.UltimateAuth.Credentials.Reference;
+
+namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore;
+
+internal static class PasswordCredentialProjectionMapper
+{
+ public static PasswordCredential ToDomain(this PasswordCredentialProjection p)
+ {
+ var security = new CredentialSecurityState(
+ revokedAt: p.RevokedAt,
+ expiresAt: p.ExpiresAt,
+ securityStamp: p.SecurityStamp);
+
+ var metadata = new CredentialMetadata
+ {
+ LastUsedAt = p.LastUsedAt,
+ Source = p.Source
+ };
+
+ return PasswordCredential.Create(
+ id: p.Id,
+ tenant: p.Tenant,
+ userKey: p.UserKey,
+ secretHash: p.SecretHash,
+ security: security,
+ metadata: metadata,
+ now: p.CreatedAt
+ );
+ }
+ public static PasswordCredentialProjection ToProjection(this PasswordCredential c)
+ {
+ return new PasswordCredentialProjection
+ {
+ Id = c.Id,
+ Tenant = c.Tenant,
+ UserKey = c.UserKey,
+ SecretHash = c.SecretHash,
+
+ RevokedAt = c.Security.RevokedAt,
+ ExpiresAt = c.Security.ExpiresAt,
+ SecurityStamp = c.Security.SecurityStamp,
+
+ LastUsedAt = c.Metadata.LastUsedAt,
+ Source = c.Metadata.Source,
+
+ CreatedAt = c.CreatedAt,
+ UpdatedAt = c.UpdatedAt,
+ DeletedAt = c.DeletedAt,
+ Version = c.Version
+ };
+ }
+
+ public static void UpdateProjection(this PasswordCredential c, PasswordCredentialProjection p)
+ {
+ p.SecretHash = c.SecretHash;
+
+ p.RevokedAt = c.Security.RevokedAt;
+ p.ExpiresAt = c.Security.ExpiresAt;
+ p.SecurityStamp = c.Security.SecurityStamp;
+
+ p.LastUsedAt = c.Metadata.LastUsedAt;
+ p.Source = c.Metadata.Source;
+
+ p.UpdatedAt = c.UpdatedAt;
+ }
+}
\ No newline at end of file
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs
new file mode 100644
index 00000000..d25e8edc
--- /dev/null
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs
@@ -0,0 +1,33 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore;
+
+internal sealed class PasswordCredentialProjection
+{
+ public Guid Id { get; set; }
+
+ public TenantKey Tenant { get; set; }
+
+ public UserKey UserKey { get; set; }
+
+ public string SecretHash { get; set; } = default!;
+
+ public DateTimeOffset? RevokedAt { get; set; }
+
+ public DateTimeOffset? ExpiresAt { get; set; }
+
+ public Guid SecurityStamp { get; set; }
+
+ public DateTimeOffset? LastUsedAt { get; set; }
+
+ public string? Source { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; }
+
+ public DateTimeOffset? UpdatedAt { get; set; }
+
+ public DateTimeOffset? DeletedAt { get; set; }
+
+ public long Version { get; set; }
+}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs
deleted file mode 100644
index 97442ecb..00000000
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/ServiceCollectionExtensions.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-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);
-
- return services;
- }
-}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs
new file mode 100644
index 00000000..ae1fe5f0
--- /dev/null
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs
@@ -0,0 +1,146 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Credentials.Contracts;
+using CodeBeam.UltimateAuth.Credentials.Reference;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore;
+
+internal sealed class EfCorePasswordCredentialStore : IPasswordCredentialStore
+{
+ private readonly UAuthCredentialDbContext _db;
+ private readonly TenantContext _tenant;
+
+ public EfCorePasswordCredentialStore(UAuthCredentialDbContext db, TenantContext tenant)
+ {
+ _db = db;
+ _tenant = tenant;
+ }
+
+ public async Task ExistsAsync(CredentialKey key, CancellationToken ct = default)
+ {
+ return await _db.PasswordCredentials
+ .AnyAsync(x =>
+ x.Id == key.Id &&
+ x.Tenant == key.Tenant,
+ ct);
+ }
+
+ public async Task AddAsync(PasswordCredential credential, CancellationToken ct = default)
+ {
+ var entity = credential.ToProjection();
+ _db.PasswordCredentials.Add(entity);
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task GetAsync(CredentialKey key, CancellationToken ct = default)
+ {
+ var entity = await _db.PasswordCredentials
+ .AsNoTracking()
+ .SingleOrDefaultAsync(
+ x => x.Id == key.Id &&
+ x.Tenant == key.Tenant,
+ ct);
+
+ return entity?.ToDomain();
+ }
+
+ public async Task SaveAsync(PasswordCredential credential, long expectedVersion, CancellationToken ct = default)
+ {
+ var entity = await _db.PasswordCredentials
+ .SingleOrDefaultAsync(x =>
+ x.Id == credential.Id &&
+ x.Tenant == credential.Tenant,
+ ct);
+
+ if (entity is null)
+ throw new UAuthNotFoundException("credential_not_found");
+
+ if (entity.Version != expectedVersion)
+ throw new UAuthConcurrencyException("credential_version_conflict");
+
+ credential.UpdateProjection(entity);
+ entity.Version++;
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default)
+ {
+ var credential = await GetAsync(key, ct);
+
+ if (credential is null)
+ throw new UAuthNotFoundException("credential_not_found");
+
+ var revoked = credential.Revoke(revokedAt);
+ await SaveAsync(revoked, expectedVersion, ct);
+ }
+
+ public async Task DeleteAsync(CredentialKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default)
+ {
+ var entity = await _db.PasswordCredentials
+ .SingleOrDefaultAsync(x =>
+ x.Id == key.Id &&
+ x.Tenant == key.Tenant,
+ ct);
+
+ if (entity is null)
+ throw new UAuthNotFoundException("credential_not_found");
+
+ if (entity.Version != expectedVersion)
+ throw new UAuthConcurrencyException("credential_version_conflict");
+
+ if (mode == DeleteMode.Hard)
+ {
+ _db.PasswordCredentials.Remove(entity);
+ }
+ else
+ {
+ entity.DeletedAt = now;
+ entity.Version++;
+ }
+
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default)
+ {
+ var entities = await _db.PasswordCredentials
+ .AsNoTracking()
+ .Where(x =>
+ x.Tenant == tenant &&
+ x.UserKey == userKey &&
+ x.DeletedAt == null)
+ .ToListAsync(ct);
+
+ return entities
+ .Select(x => x.ToDomain())
+ .ToList()
+ .AsReadOnly();
+ }
+
+ public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default)
+ {
+ var entities = await _db.PasswordCredentials
+ .Where(x =>
+ x.Tenant == tenant &&
+ x.UserKey == userKey)
+ .ToListAsync(ct);
+
+ foreach (var entity in entities)
+ {
+ if (mode == DeleteMode.Hard)
+ {
+ _db.PasswordCredentials.Remove(entity);
+ }
+ else
+ {
+ entity.DeletedAt = now;
+ entity.Version++;
+ }
+ }
+
+ await _db.SaveChangesAsync(ct);
+ }
+}
\ No newline at end of file
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs
index e4ef841f..ed3e2ade 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialSeedContributor.cs
@@ -14,12 +14,12 @@ internal sealed class InMemoryCredentialSeedContributor : ISeedContributor
private static readonly Guid _userPasswordId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
public int Order => 10;
- private readonly ICredentialStore _credentials;
+ private readonly IPasswordCredentialStore _credentials;
private readonly IInMemoryUserIdProvider _ids;
private readonly IUAuthPasswordHasher _hasher;
private readonly IClock _clock;
- public InMemoryCredentialSeedContributor(ICredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher, IClock clock)
+ public InMemoryCredentialSeedContributor(IPasswordCredentialStore credentials, IInMemoryUserIdProvider ids, IUAuthPasswordHasher hasher, IClock clock)
{
_credentials = credentials;
_ids = ids;
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs
deleted file mode 100644
index 65d6f39c..00000000
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryCredentialStore.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-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.Credentials.Contracts;
-using CodeBeam.UltimateAuth.Credentials.Reference;
-
-namespace CodeBeam.UltimateAuth.Credentials.InMemory;
-
-internal sealed class InMemoryCredentialStore : InMemoryVersionedStore, ICredentialStore
-{
- protected override CredentialKey GetKey(PasswordCredential entity) => new(entity.Tenant, entity.Id);
-
- public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default)
- {
- ct.ThrowIfCancellationRequested();
-
- var result = Values()
- .Where(c => c.Tenant == tenant && c.UserKey == userKey)
- .Cast()
- .ToArray();
-
- return Task.FromResult>(result);
- }
-
- public Task GetByIdAsync(CredentialKey key, CancellationToken ct = default)
- {
- ct.ThrowIfCancellationRequested();
-
- if (TryGet(key, out var entity))
- return Task.FromResult(entity);
-
- return Task.FromResult(entity);
- }
-
- public Task AddAsync(ICredential credential, CancellationToken ct = default)
- {
- // TODO: Implement other credential types
- if (credential is not PasswordCredential pwd)
- throw new NotSupportedException("Only password credentials are supported in-memory.");
-
- return base.AddAsync(pwd, ct);
- }
-
- public Task SaveAsync(ICredential credential, long expectedVersion, CancellationToken ct = default)
- {
- if (credential is not PasswordCredential pwd)
- throw new NotSupportedException("Only password credentials are supported in-memory.");
-
- return base.SaveAsync(pwd, expectedVersion, ct);
- }
-
- public Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default)
- {
- ct.ThrowIfCancellationRequested();
-
- if (!TryGet(key, out var credential))
- throw new UAuthNotFoundException("credential_not_found");
-
- if (credential is not PasswordCredential pwd)
- throw new NotSupportedException("Only password credentials are supported in-memory.");
-
- var revoked = pwd.Revoke(revokedAt);
-
- return SaveAsync(revoked, expectedVersion, ct);
- }
-
- public Task DeleteAsync(CredentialKey key, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default)
- {
- return base.DeleteAsync(key, expectedVersion, mode, now, ct);
- }
-
- public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default)
- {
- ct.ThrowIfCancellationRequested();
-
- var credentials = Values()
- .Where(c => c.Tenant == tenant && c.UserKey == userKey)
- .ToList();
-
- foreach (var credential in credentials)
- {
- await DeleteAsync(new CredentialKey(tenant, credential.Id), mode, now, credential.Version, ct);
- }
- }
-}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs
deleted file mode 100644
index 37be6cb4..00000000
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialState.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Domain;
-using CodeBeam.UltimateAuth.Credentials.Contracts;
-
-namespace CodeBeam.UltimateAuth.Credentials.InMemory;
-
-internal sealed class InMemoryPasswordCredentialState
-{
- public UserKey UserKey { get; init; } = default!;
- public CredentialType Type { get; } = CredentialType.Password;
-
- public string SecretHash { get; set; } = default!;
-
- public CredentialSecurityState Security { get; set; } = default!;
- public CredentialMetadata Metadata { get; set; } = default!;
-}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs
new file mode 100644
index 00000000..61f22f38
--- /dev/null
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs
@@ -0,0 +1,64 @@
+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.Credentials.Contracts;
+using CodeBeam.UltimateAuth.Credentials.Reference;
+
+namespace CodeBeam.UltimateAuth.Credentials.InMemory;
+
+internal sealed class InMemoryPasswordCredentialStore : InMemoryVersionedStore, IPasswordCredentialStore
+{
+ protected override CredentialKey GetKey(PasswordCredential entity)
+ => new(entity.Tenant, entity.Id);
+
+ protected override void BeforeAdd(PasswordCredential entity)
+ {
+ var exists = Values()
+ .Any(x =>
+ x.Tenant == entity.Tenant &&
+ x.UserKey == entity.UserKey &&
+ !x.IsDeleted);
+
+ if (exists)
+ throw new UAuthConflictException("password_credential_exists");
+ }
+
+ public Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var result = Values()
+ .Where(x =>
+ x.Tenant == tenant &&
+ x.UserKey == userKey &&
+ !x.IsDeleted)
+ .Select(x => x.Snapshot())
+ .ToList()
+ .AsReadOnly();
+
+ return Task.FromResult>(result);
+ }
+
+ public Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default)
+ {
+ if (!TryGet(key, out var credential) || credential is null)
+ throw new UAuthNotFoundException("credential_not_found");
+
+ var revoked = credential.Revoke(revokedAt);
+ return SaveAsync(revoked, expectedVersion, ct);
+ }
+
+ public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default)
+ {
+ var credentials = Values()
+ .Where(c => c.Tenant == tenant && c.UserKey == userKey)
+ .ToList();
+
+ foreach (var credential in credentials)
+ {
+ await DeleteAsync(new CredentialKey(tenant, credential.Id), credential.Version, mode, now, ct);
+ }
+ }
+}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs
index c078aca1..86118255 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs
@@ -1,4 +1,5 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Credentials.Reference;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -8,8 +9,8 @@ public static class ServiceCollectionExtensions
{
public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServiceCollection services)
{
- services.TryAddScoped();
- services.TryAddSingleton();
+ services.TryAddScoped();
+ services.TryAddSingleton();
// Never try add seed
services.AddSingleton();
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs
index 9650be6c..ee5c9c64 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs
@@ -6,13 +6,14 @@
namespace CodeBeam.UltimateAuth.Credentials.Reference;
-public sealed class PasswordCredential : ISecretCredential, ICredentialDescriptor, IVersionedEntity, IEntitySnapshot, ISoftDeletable
+public sealed class PasswordCredential : ISecretCredential, IVersionedEntity, IEntitySnapshot, ISoftDeletable
{
public Guid Id { get; init; }
public TenantKey Tenant { get; init; }
public UserKey UserKey { get; init; }
public CredentialType Type => CredentialType.Password;
+ // TODO: Add hash algorithm (PasswordHash object with hash and algorithm properties)
public string SecretHash { get; private set; } = default!;
public CredentialSecurityState Security { get; private set; } = CredentialSecurityState.Active();
public CredentialMetadata Metadata { get; private set; } = new CredentialMetadata();
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs
index 12497321..2ee133b7 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs
@@ -14,7 +14,7 @@ public static IServiceCollection AddUltimateAuthCredentialsReference(this IServi
services.TryAddScoped();
services.TryAddScoped();
services.TryAddScoped();
-
+ services.AddScoped();
return services;
}
}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/IPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/IPasswordCredentialStore.cs
new file mode 100644
index 00000000..6bdb1d05
--- /dev/null
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/IPasswordCredentialStore.cs
@@ -0,0 +1,14 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Credentials.Contracts;
+
+namespace CodeBeam.UltimateAuth.Credentials.Reference;
+
+public interface IPasswordCredentialStore : IVersionedStore
+{
+ Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default);
+ Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default);
+ Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default);
+}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialProvider.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialProvider.cs
new file mode 100644
index 00000000..cfe691bb
--- /dev/null
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialProvider.cs
@@ -0,0 +1,30 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Credentials.Reference;
+
+internal sealed class PasswordCredentialProvider : ICredentialProvider
+{
+ private readonly IPasswordCredentialStore _store;
+ private readonly ICredentialValidator _validator;
+
+ public CredentialType Type => CredentialType.Password;
+
+ public PasswordCredentialProvider(IPasswordCredentialStore store, ICredentialValidator validator)
+ {
+ _store = store;
+ _validator = validator;
+ }
+
+ public async Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default)
+ {
+ var creds = await _store.GetByUserAsync(tenant, userKey, ct);
+ return creds.Cast().ToList();
+ }
+
+ public async Task ValidateAsync(ICredential credential, string secret, CancellationToken ct = default)
+ {
+ var result = await _validator.ValidateAsync(credential, secret, ct);
+ return result.IsValid;
+ }
+}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs
index efd94263..039559f7 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs
@@ -10,11 +10,11 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference;
internal sealed class PasswordUserLifecycleIntegration : IUserLifecycleIntegration
{
- private readonly ICredentialStore _credentialStore;
+ private readonly IPasswordCredentialStore _credentialStore;
private readonly IUAuthPasswordHasher _passwordHasher;
private readonly IClock _clock;
- public PasswordUserLifecycleIntegration(ICredentialStore credentialStore, IUAuthPasswordHasher passwordHasher, IClock clock)
+ public PasswordUserLifecycleIntegration(IPasswordCredentialStore credentialStore, IUAuthPasswordHasher passwordHasher, IClock clock)
{
_credentialStore = credentialStore;
_passwordHasher = passwordHasher;
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs
index eaaa8a65..df782936 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs
@@ -17,7 +17,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference;
internal sealed class CredentialManagementService : ICredentialManagementService, IUserCredentialsInternalService
{
private readonly IAccessOrchestrator _accessOrchestrator;
- private readonly ICredentialStore _credentials;
+ private readonly IPasswordCredentialStore _credentials;
private readonly IAuthenticationSecurityManager _authenticationSecurityManager;
private readonly IOpaqueTokenGenerator _tokenGenerator;
private readonly INumericCodeGenerator _numericCodeGenerator;
@@ -30,7 +30,7 @@ internal sealed class CredentialManagementService : ICredentialManagementService
public CredentialManagementService(
IAccessOrchestrator accessOrchestrator,
- ICredentialStore credentials,
+ IPasswordCredentialStore credentials,
IAuthenticationSecurityManager authenticationSecurityManager,
IOpaqueTokenGenerator tokenGenerator,
INumericCodeGenerator numericCodeGenerator,
@@ -66,7 +66,6 @@ public async Task GetAllAsync(AccessContext context, Cance
var credentials = await _credentials.GetByUserAsync(context.ResourceTenant, subjectUser, innerCt);
var dtos = credentials
- .OfType()
.Select(c => new CredentialInfo
{
Id = c.Id,
@@ -174,7 +173,7 @@ public async Task RevokeAsync(AccessContext context, Rev
var subjectUser = context.GetTargetUserKey();
var now = _clock.UtcNow;
- var credential = await _credentials.GetByIdAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt);
+ var credential = await _credentials.GetAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt);
if (credential is not PasswordCredential pwd)
return CredentialActionResult.Fail("credential_not_found");
@@ -352,7 +351,7 @@ public async Task DeleteAsync(AccessContext context, Del
var subjectUser = context.GetTargetUserKey();
var now = _clock.UtcNow;
- var credential = await _credentials.GetByIdAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt);
+ var credential = await _credentials.GetAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt);
if (credential is not PasswordCredential pwd)
return CredentialActionResult.Fail("credential_not_found");
@@ -361,7 +360,7 @@ public async Task DeleteAsync(AccessContext context, Del
return CredentialActionResult.Fail("credential_not_found");
var oldVersion = pwd.Version;
- await _credentials.DeleteAsync(new CredentialKey(context.ResourceTenant, pwd.Id), request.Mode, now, oldVersion, innerCt);
+ await _credentials.DeleteAsync(new CredentialKey(context.ResourceTenant, pwd.Id), oldVersion, request.Mode, now, innerCt);
return CredentialActionResult.Success();
});
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs
index a9cdc74b..71b81e12 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs
@@ -1,12 +1,24 @@
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Credentials.Contracts;
namespace CodeBeam.UltimateAuth.Credentials;
public interface ICredential
{
Guid Id { get; }
- TenantKey Tenant { get; init; }
- UserKey UserKey { get; init; }
+ TenantKey Tenant { get; }
+ UserKey UserKey { get; }
CredentialType Type { get; }
+
+ CredentialSecurityState Security { get; }
+ CredentialMetadata Metadata { get; }
+
+ DateTimeOffset CreatedAt { get; }
+ DateTimeOffset? UpdatedAt { get; }
+ DateTimeOffset? DeletedAt { get; }
+
+ long Version { get; }
+
+ bool IsDeleted { get; }
}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs
deleted file mode 100644
index bc513d3a..00000000
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialDescriptor.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Domain;
-using CodeBeam.UltimateAuth.Credentials.Contracts;
-
-namespace CodeBeam.UltimateAuth.Credentials;
-
-public interface ICredentialDescriptor
-{
- Guid Id { get; }
- CredentialType Type { get; }
- CredentialSecurityState Security { get; }
- CredentialMetadata Metadata { get; }
- long Version { get; }
-}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialProvider.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialProvider.cs
new file mode 100644
index 00000000..171a7805
--- /dev/null
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialProvider.cs
@@ -0,0 +1,13 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Credentials;
+
+public interface ICredentialProvider
+{
+ CredentialType Type { get; }
+
+ Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default);
+
+ Task ValidateAsync(ICredential credential, string secret, CancellationToken ct = default);
+}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs
index 1e2f3b86..36daf785 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialStore.cs
@@ -1,17 +1,18 @@
-using CodeBeam.UltimateAuth.Core.Contracts;
-using CodeBeam.UltimateAuth.Core.Domain;
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
-using CodeBeam.UltimateAuth.Credentials.Contracts;
+//using CodeBeam.UltimateAuth.Core.Abstractions;
+//using CodeBeam.UltimateAuth.Core.Contracts;
+//using CodeBeam.UltimateAuth.Core.Domain;
+//using CodeBeam.UltimateAuth.Core.MultiTenancy;
+//using CodeBeam.UltimateAuth.Credentials.Contracts;
-namespace CodeBeam.UltimateAuth.Credentials;
+//namespace CodeBeam.UltimateAuth.Credentials;
-public interface ICredentialStore
-{
- Task>GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default);
- Task GetByIdAsync(CredentialKey key, CancellationToken ct = default);
- Task AddAsync(ICredential credential, CancellationToken ct = default);
- Task SaveAsync(ICredential credential, long expectedVersion, CancellationToken ct = default);
- Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default);
- Task DeleteAsync(CredentialKey key, DeleteMode mode, DateTimeOffset now, long expectedVersion, CancellationToken ct = default);
- Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default);
-}
+//public interface ICredentialStore : IVersionedStore
+//{
+// Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default);
+
+// Task GetByIdAsync(CredentialKey key, CancellationToken ct = default);
+
+// Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default);
+
+// Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default);
+//}
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs
index ba61b1a8..e55164ab 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs
@@ -18,7 +18,7 @@ public Task ValidateAsync(ICredential credential, st
{
ct.ThrowIfCancellationRequested();
- if (credential is ICredentialDescriptor securable)
+ if (credential is ICredential securable)
{
if (!securable.Security.IsUsable(_clock.UtcNow))
{
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/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonSerializeWrapper.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonSerializeWrapper.cs
new file mode 100644
index 00000000..52bb84c2
--- /dev/null
+++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonSerializeWrapper.cs
@@ -0,0 +1,28 @@
+using System.Text.Json;
+
+namespace CodeBeam.UltimateAuth.EntityFrameworkCore;
+
+internal static class JsonSerializerWrapper
+{
+ private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
+
+ public static string Serialize(T value)
+ {
+ return JsonSerializer.Serialize(value, Options);
+ }
+
+ public static T Deserialize(string json)
+ {
+ return JsonSerializer.Deserialize(json, Options)!;
+ }
+
+ public static string? SerializeNullable(T? value)
+ {
+ return value is null ? null : JsonSerializer.Serialize(value, Options);
+ }
+
+ public static T? DeserializeNullable(string? json)
+ {
+ return json is null ? default : JsonSerializer.Deserialize(json, Options);
+ }
+}
diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueComparers.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueComparers.cs
new file mode 100644
index 00000000..829e1700
--- /dev/null
+++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueComparers.cs
@@ -0,0 +1,64 @@
+using Microsoft.EntityFrameworkCore.ChangeTracking;
+
+namespace CodeBeam.UltimateAuth.EntityFrameworkCore;
+
+internal static class JsonValueComparerHelpers
+{
+ public static bool AreEqual(T left, T right)
+ {
+ return string.Equals(
+ JsonSerializerWrapper.Serialize(left),
+ JsonSerializerWrapper.Serialize(right),
+ StringComparison.Ordinal);
+ }
+
+ public static int GetHashCodeSafe(T value)
+ {
+ return JsonSerializerWrapper.Serialize(value).GetHashCode();
+ }
+
+ public static T Snapshot(T value)
+ {
+ var json = JsonSerializerWrapper.Serialize(value);
+ return JsonSerializerWrapper.Deserialize(json);
+ }
+
+ public static bool AreEqualNullable(T? left, T? right)
+ {
+ return string.Equals(
+ JsonSerializerWrapper.SerializeNullable(left),
+ JsonSerializerWrapper.SerializeNullable(right),
+ StringComparison.Ordinal);
+ }
+
+ public static int GetHashCodeSafeNullable(T? value)
+ {
+ var json = JsonSerializerWrapper.SerializeNullable(value);
+ return json == null ? 0 : json.GetHashCode();
+ }
+
+ public static T? SnapshotNullable(T? value)
+ {
+ var json = JsonSerializerWrapper.SerializeNullable(value);
+ return json == null ? default : JsonSerializerWrapper.Deserialize(json);
+ }
+}
+
+public static class JsonValueComparers
+{
+ public static ValueComparer Create()
+ {
+ return new ValueComparer(
+ (l, r) => JsonValueComparerHelpers.AreEqual(l, r),
+ v => JsonValueComparerHelpers.GetHashCodeSafe(v),
+ v => JsonValueComparerHelpers.Snapshot(v));
+ }
+
+ public static ValueComparer CreateNullable()
+ {
+ return new ValueComparer(
+ (l, r) => JsonValueComparerHelpers.AreEqualNullable(l, r),
+ v => JsonValueComparerHelpers.GetHashCodeSafeNullable(v),
+ v => JsonValueComparerHelpers.SnapshotNullable(v));
+ }
+}
\ No newline at end of file
diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueConverter.cs
new file mode 100644
index 00000000..44d6293d
--- /dev/null
+++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/JsonValueConverter.cs
@@ -0,0 +1,18 @@
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using System.Text.Json;
+
+namespace CodeBeam.UltimateAuth.EntityFrameworkCore;
+
+public sealed class JsonValueConverter : ValueConverter
+{
+ private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
+
+ public JsonValueConverter()
+ : base(v => Serialize(v), v => Deserialize(v))
+ {
+ }
+
+ private static string Serialize(T value) => JsonSerializer.Serialize(value, Options);
+
+ private static T Deserialize(string json) => JsonSerializer.Deserialize(json, Options)!;
+}
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/NullableJsonValueConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableJsonValueConverter.cs
new file mode 100644
index 00000000..f1ac4d05
--- /dev/null
+++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore.Abstractions/Infrastructure/NullableJsonValueConverter.cs
@@ -0,0 +1,18 @@
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using System.Text.Json;
+
+namespace CodeBeam.UltimateAuth.EntityFrameworkCore;
+
+public sealed class NullableJsonValueConverter : ValueConverter
+{
+ private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
+
+ public NullableJsonValueConverter()
+ : base(v => Serialize(v), v => Deserialize(v))
+ {
+ }
+
+ private static string? Serialize(T? value) => value == null ? null : JsonSerializer.Serialize(value, Options);
+
+ private static T? Deserialize(string? json) => json == null ? default : JsonSerializer.Deserialize(json, Options);
+}
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 b7f0977d..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;
@@ -34,8 +35,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 +54,7 @@ protected override void OnModelCreating(ModelBuilder b)
.HasConversion(
v => v.Value,
v => SessionRootId.From(v))
+ .HasMaxLength(128)
.IsRequired();
});
@@ -56,11 +63,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 +89,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 +115,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/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/Infrastructure/JsonValueConverter.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs
deleted file mode 100644
index 68fb5ff1..00000000
--- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-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
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..6377ea28 100644
--- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs
+++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs
@@ -13,9 +13,9 @@ public static UAuthSession ToDomain(this SessionProjection p)
p.ChainId,
p.CreatedAt,
p.ExpiresAt,
- p.IsRevoked,
p.RevokedAt,
p.SecurityVersionAtCreation,
+ p.Device,
p.Claims,
p.Metadata,
p.Version
@@ -33,15 +33,13 @@ public static SessionProjection ToProjection(this UAuthSession s)
CreatedAt = s.CreatedAt,
ExpiresAt = s.ExpiresAt,
-
- IsRevoked = s.IsRevoked,
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/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/EntityProjections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs
similarity index 90%
rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs
rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs
index 7106b024..ffc23f10 100644
--- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionChainProjection.cs
+++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs
@@ -15,7 +15,8 @@ internal sealed class SessionChainProjection
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset LastSeenAt { get; set; }
public DateTimeOffset? AbsoluteExpiresAt { get; set; }
- public DeviceContext Device { get; set; }
+ public DeviceId DeviceId { 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 91%
rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs
rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs
index 58c37604..19061c50 100644
--- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionProjection.cs
+++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/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/Projections/SessionRootProjection.cs
similarity index 92%
rename from src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs
rename to src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs
index 5d1e613f..4d9c0ff8 100644
--- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/EntityProjections/SessionRootProjection.cs
+++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/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/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)
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/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/AssemblyVisibility.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/AssemblyVisibility.cs
new file mode 100644
index 00000000..ed166fcc
--- /dev/null
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/AssemblyVisibility.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")]
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/CodeBeam.UltimateAuth.Users.EntityFrameworkCore.csproj b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/CodeBeam.UltimateAuth.Users.EntityFrameworkCore.csproj
new file mode 100644
index 00000000..985dcf63
--- /dev/null
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/CodeBeam.UltimateAuth.Users.EntityFrameworkCore.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net8.0;net9.0;net10.0
+ enable
+ enable
+ true
+ $(NoWarn);1591
+
+
+
+
+
+
+
+
+
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs
new file mode 100644
index 00000000..9cf9db39
--- /dev/null
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs
@@ -0,0 +1,153 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.ChangeTracking;
+using System.Text.Json;
+
+namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore;
+
+internal sealed class UAuthUserDbContext : DbContext
+{
+ public DbSet Identifiers => Set();
+ public DbSet Lifecycles => Set();
+ public DbSet Profiles => Set();
+
+ private readonly TenantContext _tenant;
+
+ public UAuthUserDbContext(DbContextOptions options, TenantContext tenant)
+ : base(options)
+ {
+ _tenant = tenant;
+ }
+
+ protected override void OnModelCreating(ModelBuilder b)
+ {
+ ConfigureTenantFilters(b);
+
+ ConfigureIdentifiers(b);
+ ConfigureLifecycles(b);
+ ConfigureProfiles(b);
+ }
+
+ private void ConfigureTenantFilters(ModelBuilder b)
+ {
+ b.Entity()
+ .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant);
+
+ b.Entity()
+ .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant);
+
+ b.Entity()
+ .HasQueryFilter(x => _tenant.IsGlobal || x.Tenant == _tenant.Tenant);
+ }
+
+ private void ConfigureIdentifiers(ModelBuilder b)
+ {
+ b.Entity(e =>
+ {
+ e.HasKey(x => x.Id);
+
+ e.Property(x => x.Version)
+ .IsConcurrencyToken();
+
+ e.Property(x => x.Tenant)
+ .HasConversion(
+ v => v.Value,
+ v => TenantKey.FromInternal(v))
+ .HasMaxLength(128)
+ .IsRequired();
+
+ e.Property(x => x.UserKey)
+ .HasConversion(
+ v => v.Value,
+ v => UserKey.FromString(v))
+ .HasMaxLength(128)
+ .IsRequired();
+
+ e.Property(x => x.Value)
+ .HasMaxLength(256)
+ .IsRequired();
+
+ e.Property(x => x.NormalizedValue)
+ .HasMaxLength(256)
+ .IsRequired();
+
+ e.HasIndex(x => new { x.Tenant, x.Type, x.NormalizedValue }).IsUnique();
+ e.HasIndex(x => new { x.Tenant, x.UserKey });
+ e.HasIndex(x => new { x.Tenant, x.UserKey, x.Type, x.IsPrimary });
+ e.HasIndex(x => new { x.Tenant, x.UserKey, x.IsPrimary });
+ e.HasIndex(x => new { x.Tenant, x.NormalizedValue });
+
+ e.Property(x => x.CreatedAt)
+ .IsRequired();
+ });
+ }
+
+ private void ConfigureLifecycles(ModelBuilder b)
+ {
+ b.Entity(e =>
+ {
+ e.HasKey(x => x.Id);
+
+ e.Property(x => x.Version)
+ .IsConcurrencyToken();
+
+ e.Property(x => x.Tenant)
+ .HasConversion(
+ v => v.Value,
+ v => TenantKey.FromInternal(v))
+ .HasMaxLength(128)
+ .IsRequired();
+
+ e.Property(x => x.UserKey)
+ .HasConversion(
+ v => v.Value,
+ v => UserKey.FromString(v))
+ .HasMaxLength(128)
+ .IsRequired();
+
+ e.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique();
+
+ e.Property(x => x.SecurityVersion)
+ .IsRequired();
+
+ e.Property(x => x.CreatedAt)
+ .IsRequired();
+ });
+ }
+
+ private void ConfigureProfiles(ModelBuilder b)
+ {
+ b.Entity(e =>
+ {
+ e.HasKey(x => x.Id);
+
+ e.Property(x => x.Version)
+ .IsConcurrencyToken();
+
+ e.Property(x => x.Tenant)
+ .HasConversion(
+ v => v.Value,
+ v => TenantKey.FromInternal(v))
+ .HasMaxLength(128)
+ .IsRequired();
+
+ e.Property(x => x.UserKey)
+ .HasConversion(
+ v => v.Value,
+ v => UserKey.FromString(v))
+ .HasMaxLength(128)
+ .IsRequired();
+
+ e.HasIndex(x => new { x.Tenant, x.UserKey });
+
+ e.Property(x => x.Metadata)
+ .HasConversion(new NullableJsonValueConverter>())
+ .Metadata.SetValueComparer(JsonValueComparers.Create());
+
+ e.Property(x => x.CreatedAt)
+ .IsRequired();
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 00000000..5a5ed1ba
--- /dev/null
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,17 @@
+using CodeBeam.UltimateAuth.Users.Reference;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddUltimateAuthEntityFrameworkCoreUsers(this IServiceCollection services, Action configureDb)
+ {
+ services.AddDbContextPool(configureDb);
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ return services;
+ }
+}
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserIdentifierMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserIdentifierMapper.cs
new file mode 100644
index 00000000..0f7b2e88
--- /dev/null
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserIdentifierMapper.cs
@@ -0,0 +1,42 @@
+using CodeBeam.UltimateAuth.Users.Reference;
+
+namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore;
+
+internal static class UserIdentifierMapper
+{
+ public static UserIdentifier ToDomain(this UserIdentifierProjection p)
+ {
+ return UserIdentifier.FromProjection(
+ p.Id,
+ p.Tenant,
+ p.UserKey,
+ p.Type,
+ p.Value,
+ p.NormalizedValue,
+ p.IsPrimary,
+ p.CreatedAt,
+ p.VerifiedAt,
+ p.UpdatedAt,
+ p.DeletedAt,
+ p.Version);
+ }
+
+ public static UserIdentifierProjection ToProjection(this UserIdentifier d)
+ {
+ return new UserIdentifierProjection
+ {
+ Id = d.Id,
+ Tenant = d.Tenant,
+ UserKey = d.UserKey,
+ Type = d.Type,
+ Value = d.Value,
+ NormalizedValue = d.NormalizedValue,
+ IsPrimary = d.IsPrimary,
+ CreatedAt = d.CreatedAt,
+ VerifiedAt = d.VerifiedAt,
+ UpdatedAt = d.UpdatedAt,
+ DeletedAt = d.DeletedAt,
+ Version = d.Version
+ };
+ }
+}
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserLifecycleMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserLifecycleMapper.cs
new file mode 100644
index 00000000..654600c3
--- /dev/null
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserLifecycleMapper.cs
@@ -0,0 +1,36 @@
+using CodeBeam.UltimateAuth.Users.Reference;
+
+namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore;
+
+internal static class UserLifecycleMapper
+{
+ public static UserLifecycle ToDomain(this UserLifecycleProjection p)
+ {
+ return UserLifecycle.FromProjection(
+ p.Id,
+ p.Tenant,
+ p.UserKey,
+ p.Status,
+ p.SecurityVersion,
+ p.CreatedAt,
+ p.UpdatedAt,
+ p.DeletedAt,
+ p.Version);
+ }
+
+ public static UserLifecycleProjection ToProjection(this UserLifecycle d)
+ {
+ return new UserLifecycleProjection
+ {
+ Id = d.Id,
+ Tenant = d.Tenant,
+ UserKey = d.UserKey,
+ Status = d.Status,
+ SecurityVersion = d.SecurityVersion,
+ CreatedAt = d.CreatedAt,
+ UpdatedAt = d.UpdatedAt,
+ DeletedAt = d.DeletedAt,
+ Version = d.Version
+ };
+ }
+}
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs
new file mode 100644
index 00000000..d18a1bcb
--- /dev/null
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs
@@ -0,0 +1,52 @@
+using CodeBeam.UltimateAuth.Users.Reference;
+
+namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore;
+
+internal static class UserProfileMapper
+{
+ public static UserProfile ToDomain(this UserProfileProjection p)
+ {
+ return UserProfile.FromProjection(
+ p.Id,
+ p.Tenant,
+ p.UserKey,
+ p.FirstName,
+ p.LastName,
+ p.DisplayName,
+ p.BirthDate,
+ p.Gender,
+ p.Bio,
+ p.Language,
+ p.TimeZone,
+ p.Culture,
+ p.Metadata,
+ p.CreatedAt,
+ p.UpdatedAt,
+ p.DeletedAt,
+ p.Version);
+ }
+
+ public static UserProfileProjection ToProjection(this UserProfile d)
+ {
+ return new UserProfileProjection
+ {
+ Id = d.Id,
+ Tenant = d.Tenant,
+ UserKey = d.UserKey,
+ FirstName = d.FirstName,
+ LastName = d.LastName,
+ DisplayName = d.DisplayName,
+ BirthDate = d.BirthDate,
+ Gender = d.Gender,
+ Bio = d.Bio,
+ Language = d.Language,
+ TimeZone = d.TimeZone,
+ Culture = d.Culture,
+ Metadata = d.Metadata?.ToDictionary(x => x.Key, x => x.Value),
+ CreatedAt = d.CreatedAt,
+ UpdatedAt = d.UpdatedAt,
+ DeletedAt = d.DeletedAt,
+ Version = d.Version
+ };
+ }
+}
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserIdentifierProjections.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserIdentifierProjections.cs
new file mode 100644
index 00000000..1600e974
--- /dev/null
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserIdentifierProjections.cs
@@ -0,0 +1,32 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Users.Contracts;
+
+namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore;
+
+internal sealed class UserIdentifierProjection
+{
+ public Guid Id { get; set; }
+
+ public TenantKey Tenant { get; set; }
+
+ public UserKey UserKey { get; set; } = default!;
+
+ public UserIdentifierType Type { get; set; }
+
+ public string Value { get; set; } = default!;
+
+ public string NormalizedValue { get; set; } = default!;
+
+ public bool IsPrimary { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; }
+
+ public DateTimeOffset? VerifiedAt { get; set; }
+
+ public DateTimeOffset? UpdatedAt { get; set; }
+
+ public DateTimeOffset? DeletedAt { get; set; }
+
+ public long Version { get; set; }
+}
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserLifecycleProjection.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserLifecycleProjection.cs
new file mode 100644
index 00000000..0f33546f
--- /dev/null
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserLifecycleProjection.cs
@@ -0,0 +1,26 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore;
+
+internal sealed class UserLifecycleProjection
+{
+ public Guid Id { get; set; }
+
+ public TenantKey Tenant { get; set; }
+
+ public UserKey UserKey { get; set; } = default!;
+
+ public UserStatus Status { get; set; }
+
+ public long SecurityVersion { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; }
+
+ public DateTimeOffset? UpdatedAt { get; set; }
+
+ public DateTimeOffset? DeletedAt { get; set; }
+
+ public long Version { get; set; }
+}
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs
new file mode 100644
index 00000000..6e5f07cd
--- /dev/null
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs
@@ -0,0 +1,41 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore;
+
+internal sealed class UserProfileProjection
+{
+ public Guid Id { get; set; }
+
+ public TenantKey Tenant { get; set; }
+
+ public UserKey UserKey { get; set; } = default!;
+
+ public string? FirstName { get; set; }
+
+ public string? LastName { get; set; }
+
+ public string? DisplayName { get; set; }
+
+ public DateOnly? BirthDate { get; set; }
+
+ public string? Gender { get; set; }
+
+ public string? Bio { get; set; }
+
+ public string? Language { get; set; }
+
+ public string? TimeZone { get; set; }
+
+ public string? Culture { get; set; }
+
+ public Dictionary? Metadata { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; }
+
+ public DateTimeOffset? UpdatedAt { get; set; }
+
+ public DateTimeOffset? DeletedAt { get; set; }
+
+ public long Version { get; set; }
+}
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs
new file mode 100644
index 00000000..86fd189f
--- /dev/null
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs
@@ -0,0 +1,322 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Users.Contracts;
+using CodeBeam.UltimateAuth.Users.Reference;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore;
+
+internal sealed class EfCoreUserIdentifierStore : IUserIdentifierStore
+{
+ private readonly UAuthUserDbContext _db;
+
+ public EfCoreUserIdentifierStore(UAuthUserDbContext db)
+ {
+ _db = db;
+ }
+
+ public async Task ExistsAsync(Guid key, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ return await _db.Identifiers
+ .AnyAsync(x => x.Id == key, ct);
+ }
+
+ public async Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var q = _db.Identifiers
+ .AsNoTracking()
+ .Where(x =>
+ x.Tenant == query.Tenant &&
+ x.Type == query.Type &&
+ x.NormalizedValue == query.NormalizedValue &&
+ x.DeletedAt == null);
+
+ if (query.ExcludeIdentifierId.HasValue)
+ q = q.Where(x => x.Id != query.ExcludeIdentifierId.Value);
+
+ q = query.Scope switch
+ {
+ IdentifierExistenceScope.WithinUser =>
+ q.Where(x => x.UserKey == query.UserKey),
+
+ IdentifierExistenceScope.TenantPrimaryOnly =>
+ q.Where(x => x.IsPrimary),
+
+ IdentifierExistenceScope.TenantAny =>
+ q,
+
+ _ => q
+ };
+
+ var match = await q
+ .Select(x => new
+ {
+ x.Id,
+ x.UserKey,
+ x.IsPrimary
+ })
+ .FirstOrDefaultAsync(ct);
+
+ if (match is null)
+ return new IdentifierExistenceResult(false);
+
+ return new IdentifierExistenceResult(
+ true,
+ match.UserKey,
+ match.Id,
+ match.IsPrimary);
+ }
+
+ public async Task GetAsync(Guid key, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projection = await _db.Identifiers
+ .AsNoTracking()
+ .SingleOrDefaultAsync(x => x.Id == key, ct);
+
+ return projection?.ToDomain();
+ }
+
+ public async Task GetAsync(TenantKey tenant, UserIdentifierType type, string normalizedValue, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projection = await _db.Identifiers
+ .AsNoTracking()
+ .SingleOrDefaultAsync(
+ x =>
+ x.Tenant == tenant &&
+ x.Type == type &&
+ x.NormalizedValue == normalizedValue &&
+ x.DeletedAt == null,
+ ct);
+
+ return projection?.ToDomain();
+ }
+
+ public async Task GetByIdAsync(Guid id, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projection = await _db.Identifiers
+ .AsNoTracking()
+ .SingleOrDefaultAsync(x => x.Id == id, ct);
+
+ return projection?.ToDomain();
+ }
+
+ public async Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projections = await _db.Identifiers
+ .AsNoTracking()
+ .Where(x =>
+ x.Tenant == tenant &&
+ x.UserKey == userKey &&
+ x.DeletedAt == null)
+ .OrderBy(x => x.CreatedAt)
+ .ToListAsync(ct);
+
+ return projections.Select(x => x.ToDomain()).ToList();
+ }
+
+ public async Task AddAsync(UserIdentifier entity, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projection = entity.ToProjection();
+
+ if (entity.Version != 0)
+ throw new UAuthValidationException("New identifier must have version 0.");
+
+ using var tx = await _db.Database.BeginTransactionAsync(ct);
+
+ if (entity.IsPrimary)
+ {
+ await _db.Identifiers
+ .Where(x =>
+ x.Tenant == entity.Tenant &&
+ x.UserKey == entity.UserKey &&
+ x.Type == entity.Type &&
+ x.IsPrimary &&
+ x.DeletedAt == null)
+ .ExecuteUpdateAsync(
+ x => x.SetProperty(i => i.IsPrimary, false),
+ ct);
+ }
+
+ _db.Identifiers.Add(projection);
+
+ await _db.SaveChangesAsync(ct);
+ await tx.CommitAsync(ct);
+ }
+
+ public async Task SaveAsync(UserIdentifier entity, long expectedVersion, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projection = entity.ToProjection();
+
+ using var tx = await _db.Database.BeginTransactionAsync(ct);
+
+ if (entity.IsPrimary)
+ {
+ await _db.Identifiers
+ .Where(x =>
+ x.Tenant == entity.Tenant &&
+ x.UserKey == entity.UserKey &&
+ x.Type == entity.Type &&
+ x.Id != entity.Id &&
+ x.IsPrimary &&
+ x.DeletedAt == null)
+ .ExecuteUpdateAsync(
+ x => x.SetProperty(i => i.IsPrimary, false),
+ ct);
+ }
+
+ _db.Entry(projection).State = EntityState.Modified;
+
+ _db.Entry(projection)
+ .Property(x => x.Version)
+ .OriginalValue = expectedVersion;
+
+ try
+ {
+ await _db.SaveChangesAsync(ct);
+ await tx.CommitAsync(ct);
+ }
+ catch (DbUpdateConcurrencyException)
+ {
+ throw new UAuthConcurrencyException("identifier_concurrency_conflict");
+ }
+ }
+
+ public async Task> QueryAsync(TenantKey tenant, UserIdentifierQuery query, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ if (query.UserKey is null)
+ throw new UAuthIdentifierValidationException("userKey_required");
+
+ var normalized = query.Normalize();
+
+ var baseQuery = _db.Identifiers
+ .AsNoTracking()
+ .Where(x =>
+ x.Tenant == tenant &&
+ x.UserKey == query.UserKey &&
+ (query.IncludeDeleted || x.DeletedAt == null));
+
+ baseQuery = query.SortBy switch
+ {
+ nameof(UserIdentifier.Type) =>
+ query.Descending
+ ? baseQuery.OrderByDescending(x => x.Type)
+ : baseQuery.OrderBy(x => x.Type),
+
+ nameof(UserIdentifier.CreatedAt) =>
+ query.Descending
+ ? baseQuery.OrderByDescending(x => x.CreatedAt)
+ : baseQuery.OrderBy(x => x.CreatedAt),
+
+ nameof(UserIdentifier.Value) =>
+ query.Descending
+ ? baseQuery.OrderByDescending(x => x.Value)
+ : baseQuery.OrderBy(x => x.Value),
+
+ _ => baseQuery.OrderBy(x => x.CreatedAt)
+ };
+
+ var total = await baseQuery.CountAsync(ct);
+
+ var items = await baseQuery
+ .Skip((normalized.PageNumber - 1) * normalized.PageSize)
+ .Take(normalized.PageSize)
+ .ToListAsync(ct);
+
+ return new PagedResult(
+ items.Select(x => x.ToDomain()).ToList(),
+ total,
+ normalized.PageNumber,
+ normalized.PageSize,
+ query.SortBy,
+ query.Descending);
+ }
+
+ public async Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projections = await _db.Identifiers
+ .AsNoTracking()
+ .Where(x =>
+ x.Tenant == tenant &&
+ userKeys.Contains(x.UserKey) &&
+ x.DeletedAt == null)
+ .ToListAsync(ct);
+
+ return projections.Select(x => x.ToDomain()).ToList();
+ }
+
+ public async Task DeleteAsync(Guid key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projection = await _db.Identifiers
+ .SingleOrDefaultAsync(x => x.Id == key, ct);
+
+ if (projection is null)
+ throw new UAuthNotFoundException("identifier_not_found");
+
+ if (projection.Version != expectedVersion)
+ throw new UAuthConcurrencyException("identifier_concurrency_conflict");
+
+ if (mode == DeleteMode.Hard)
+ {
+ _db.Identifiers.Remove(projection);
+ }
+ else
+ {
+ projection.DeletedAt = now;
+ projection.IsPrimary = false;
+ projection.Version++;
+ }
+
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task DeleteByUserAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ if (mode == DeleteMode.Hard)
+ {
+ await _db.Identifiers
+ .Where(x =>
+ x.Tenant == tenant &&
+ x.UserKey == userKey)
+ .ExecuteDeleteAsync(ct);
+
+ return;
+ }
+
+ await _db.Identifiers
+ .Where(x =>
+ x.Tenant == tenant &&
+ x.UserKey == userKey &&
+ x.DeletedAt == null)
+ .ExecuteUpdateAsync(
+ x => x
+ .SetProperty(i => i.DeletedAt, deletedAt)
+ .SetProperty(i => i.IsPrimary, false),
+ ct);
+ }
+}
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs
new file mode 100644
index 00000000..c2b35917
--- /dev/null
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs
@@ -0,0 +1,159 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Users.Reference;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore;
+
+internal sealed class EfCoreUserLifecycleStore : IUserLifecycleStore
+{
+ private readonly UAuthUserDbContext _db;
+
+ public EfCoreUserLifecycleStore(UAuthUserDbContext db)
+ {
+ _db = db;
+ }
+
+ public async Task GetAsync(UserLifecycleKey key, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projection = await _db.Lifecycles
+ .AsNoTracking()
+ .SingleOrDefaultAsync(
+ x => x.Tenant == key.Tenant &&
+ x.UserKey == key.UserKey,
+ ct);
+
+ return projection?.ToDomain();
+ }
+
+ public async Task ExistsAsync(UserLifecycleKey key, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ return await _db.Lifecycles
+ .AnyAsync(
+ x => x.Tenant == key.Tenant &&
+ x.UserKey == key.UserKey,
+ ct);
+ }
+
+ public async Task AddAsync(UserLifecycle entity, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projection = entity.ToProjection();
+
+ if (entity.Version != 0)
+ throw new InvalidOperationException("New lifecycle must have version 0.");
+
+ _db.Lifecycles.Add(projection);
+
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task SaveAsync(UserLifecycle entity, long expectedVersion, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projection = entity.ToProjection();
+
+ _db.Entry(projection).State = EntityState.Modified;
+
+ _db.Entry(projection)
+ .Property(x => x.Version)
+ .OriginalValue = expectedVersion;
+
+ try
+ {
+ await _db.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateConcurrencyException)
+ {
+ throw new UAuthConcurrencyException("user_lifecycle_concurrency_conflict");
+ }
+ }
+
+ public async Task DeleteAsync(UserLifecycleKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projection = await _db.Lifecycles
+ .SingleOrDefaultAsync(
+ x => x.Tenant == key.Tenant &&
+ x.UserKey == key.UserKey,
+ ct);
+
+ if (projection is null)
+ throw new UAuthNotFoundException("user_lifecycle_not_found");
+
+ if (projection.Version != expectedVersion)
+ throw new UAuthConcurrencyException("user_lifecycle_concurrency_conflict");
+
+ if (mode == DeleteMode.Hard)
+ {
+ _db.Lifecycles.Remove(projection);
+ }
+ else
+ {
+ projection.DeletedAt = now;
+ projection.Version++;
+ }
+
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task> QueryAsync(TenantKey tenant, UserLifecycleQuery query, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var normalized = query.Normalize();
+
+ var baseQuery = _db.Lifecycles
+ .AsNoTracking()
+ .Where(x => x.Tenant == tenant);
+
+ if (!query.IncludeDeleted)
+ baseQuery = baseQuery.Where(x => x.DeletedAt == null);
+
+ if (query.Status != null)
+ baseQuery = baseQuery.Where(x => x.Status == query.Status);
+
+ baseQuery = query.SortBy switch
+ {
+ nameof(UserLifecycle.Id) =>
+ query.Descending
+ ? baseQuery.OrderByDescending(x => x.Id)
+ : baseQuery.OrderBy(x => x.Id),
+
+ nameof(UserLifecycle.CreatedAt) =>
+ query.Descending
+ ? baseQuery.OrderByDescending(x => x.CreatedAt)
+ : baseQuery.OrderBy(x => x.CreatedAt),
+
+ nameof(UserLifecycle.Status) =>
+ query.Descending
+ ? baseQuery.OrderByDescending(x => x.Status)
+ : baseQuery.OrderBy(x => x.Status),
+
+ _ => baseQuery.OrderBy(x => x.CreatedAt)
+ };
+
+ var total = await baseQuery.CountAsync(ct);
+
+ var items = await baseQuery
+ .Skip((normalized.PageNumber - 1) * normalized.PageSize)
+ .Take(normalized.PageSize)
+ .ToListAsync(ct);
+
+ return new PagedResult(
+ items.Select(x => x.ToDomain()).ToList(),
+ total,
+ normalized.PageNumber,
+ normalized.PageSize,
+ query.SortBy,
+ query.Descending);
+ }
+}
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs
new file mode 100644
index 00000000..cf7286de
--- /dev/null
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs
@@ -0,0 +1,163 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Users.Reference;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore;
+
+internal sealed class EfCoreUserProfileStore : IUserProfileStore
+{
+ private readonly UAuthUserDbContext _db;
+
+ public EfCoreUserProfileStore(UAuthUserDbContext db)
+ {
+ _db = db;
+ }
+
+ public async Task GetAsync(UserProfileKey key, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projection = await _db.Profiles
+ .AsNoTracking()
+ .SingleOrDefaultAsync(x =>
+ x.Tenant == key.Tenant &&
+ x.UserKey == key.UserKey,
+ ct);
+
+ return projection?.ToDomain();
+ }
+
+ public async Task ExistsAsync(UserProfileKey key, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ return await _db.Profiles
+ .AnyAsync(x =>
+ x.Tenant == key.Tenant &&
+ x.UserKey == key.UserKey,
+ ct);
+ }
+
+ public async Task AddAsync(UserProfile entity, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projection = entity.ToProjection();
+ _db.Profiles.Add(projection);
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task SaveAsync(UserProfile entity, long expectedVersion, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projection = entity.ToProjection();
+
+ if (entity.Version != expectedVersion + 1)
+ throw new InvalidOperationException("Profile version must be incremented by domain.");
+
+ _db.Entry(projection).State = EntityState.Modified;
+ _db.Entry(projection).Property(x => x.Version).OriginalValue = expectedVersion;
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task DeleteAsync(UserProfileKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projection = await _db.Profiles
+ .SingleOrDefaultAsync(x =>
+ x.Tenant == key.Tenant &&
+ x.UserKey == key.UserKey,
+ ct);
+
+ if (projection is null)
+ throw new UAuthNotFoundException("user_profile_not_found");
+
+ if (projection.Version != expectedVersion)
+ throw new UAuthConcurrencyException("user_profile_concurrency_conflict");
+
+ if (mode == DeleteMode.Hard)
+ {
+ _db.Profiles.Remove(projection);
+ }
+ else
+ {
+ projection.DeletedAt = now;
+ projection.Version++;
+ }
+
+ await _db.SaveChangesAsync(ct);
+ }
+
+ public async Task> QueryAsync(TenantKey tenant, UserProfileQuery query, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var normalized = query.Normalize();
+
+ var baseQuery = _db.Profiles
+ .AsNoTracking()
+ .Where(x => x.Tenant == tenant);
+
+ if (!query.IncludeDeleted)
+ baseQuery = baseQuery.Where(x => x.DeletedAt == null);
+
+ baseQuery = query.SortBy switch
+ {
+ nameof(UserProfile.CreatedAt) =>
+ query.Descending
+ ? baseQuery.OrderByDescending(x => x.CreatedAt)
+ : baseQuery.OrderBy(x => x.CreatedAt),
+
+ nameof(UserProfile.DisplayName) =>
+ query.Descending
+ ? baseQuery.OrderByDescending(x => x.DisplayName)
+ : baseQuery.OrderBy(x => x.DisplayName),
+
+ nameof(UserProfile.FirstName) =>
+ query.Descending
+ ? baseQuery.OrderByDescending(x => x.FirstName)
+ : baseQuery.OrderBy(x => x.FirstName),
+
+ nameof(UserProfile.LastName) =>
+ query.Descending
+ ? baseQuery.OrderByDescending(x => x.LastName)
+ : baseQuery.OrderBy(x => x.LastName),
+
+ _ => baseQuery.OrderBy(x => x.CreatedAt)
+ };
+
+ var total = await baseQuery.CountAsync(ct);
+
+ var items = await baseQuery
+ .Skip((normalized.PageNumber - 1) * normalized.PageSize)
+ .Take(normalized.PageSize)
+ .ToListAsync(ct);
+
+ return new PagedResult(
+ items.Select(x => x.ToDomain()).ToList(),
+ total,
+ normalized.PageNumber,
+ normalized.PageSize,
+ query.SortBy,
+ query.Descending);
+ }
+
+ public async Task> GetByUsersAsync(TenantKey tenant, IReadOnlyList userKeys, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var projections = await _db.Profiles
+ .AsNoTracking()
+ .Where(x => x.Tenant == tenant)
+ .Where(x => userKeys.Contains(x.UserKey))
+ .Where(x => x.DeletedAt == null)
+ .ToListAsync(ct);
+
+ return projections.Select(x => x.ToDomain()).ToList();
+ }
+}
diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs
index a38424d4..f47dee0b 100644
--- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs
+++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs
@@ -144,6 +144,37 @@ public UserIdentifier MarkDeleted(DateTimeOffset at)
return this;
}
+ public static UserIdentifier FromProjection(
+ Guid id,
+ TenantKey tenant,
+ UserKey userKey,
+ UserIdentifierType type,
+ string value,
+ string normalizedValue,
+ bool isPrimary,
+ DateTimeOffset createdAt,
+ DateTimeOffset? verifiedAt,
+ DateTimeOffset? updatedAt,
+ DateTimeOffset? deletedAt,
+ long version)
+ {
+ return new UserIdentifier
+ {
+ Id = id,
+ Tenant = tenant,
+ UserKey = userKey,
+ Type = type,
+ Value = value,
+ NormalizedValue = normalizedValue,
+ IsPrimary = isPrimary,
+ CreatedAt = createdAt,
+ VerifiedAt = verifiedAt,
+ UpdatedAt = updatedAt,
+ DeletedAt = deletedAt,
+ Version = version
+ };
+ }
+
public UserIdentifierInfo ToDto()
{
return new UserIdentifierInfo()
diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs
index 8ee2470b..b0b64139 100644
--- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs
+++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs
@@ -93,4 +93,29 @@ public UserLifecycle IncrementSecurityVersion()
SecurityVersion++;
return this;
}
+
+ public static UserLifecycle FromProjection(
+ Guid id,
+ TenantKey tenant,
+ UserKey userKey,
+ UserStatus status,
+ long securityVersion,
+ DateTimeOffset createdAt,
+ DateTimeOffset? updatedAt,
+ DateTimeOffset? deletedAt,
+ long version)
+ {
+ return new UserLifecycle
+ {
+ Id = id,
+ Tenant = tenant,
+ UserKey = userKey,
+ Status = status,
+ SecurityVersion = securityVersion,
+ CreatedAt = createdAt,
+ UpdatedAt = updatedAt,
+ DeletedAt = deletedAt,
+ Version = version
+ };
+ }
}
diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs
index ff0227b4..fbf58740 100644
--- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs
+++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs
@@ -164,4 +164,45 @@ public UserProfile MarkDeleted(DateTimeOffset now)
return this;
}
+
+ public static UserProfile FromProjection(
+ Guid id,
+ TenantKey tenant,
+ UserKey userKey,
+ string? firstName,
+ string? lastName,
+ string? displayName,
+ DateOnly? birthDate,
+ string? gender,
+ string? bio,
+ string? language,
+ string? timeZone,
+ string? culture,
+ IReadOnlyDictionary? metadata,
+ DateTimeOffset createdAt,
+ DateTimeOffset? updatedAt,
+ DateTimeOffset? deletedAt,
+ long version)
+ {
+ return new UserProfile
+ {
+ Id = id,
+ Tenant = tenant,
+ UserKey = userKey,
+ FirstName = firstName,
+ LastName = lastName,
+ DisplayName = displayName,
+ BirthDate = birthDate,
+ Gender = gender,
+ Bio = bio,
+ Language = language,
+ TimeZone = timeZone,
+ Culture = culture,
+ Metadata = metadata,
+ CreatedAt = createdAt,
+ UpdatedAt = updatedAt,
+ DeletedAt = deletedAt,
+ Version = version
+ };
+ }
}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj
index f0adf68b..216e9045 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj
@@ -31,6 +31,7 @@
+
@@ -38,6 +39,7 @@
+
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/Credentials/CredentialUserMappingBuilderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs
deleted file mode 100644
index 6ab9d80f..00000000
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialUserMappingBuilderTests.cs
+++ /dev/null
@@ -1,94 +0,0 @@
-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);
- }
-}
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}");