diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx
index 18a8f98..8f20ba9 100644
--- a/UltimateAuth.slnx
+++ b/UltimateAuth.slnx
@@ -3,7 +3,7 @@
-
+
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs
new file mode 100644
index 0000000..9da4a8f
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs
@@ -0,0 +1,10 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Core.Abstractions
+{
+ public interface IAuthAuthority
+ {
+ AuthorizationResult Decide(AuthContext context);
+ }
+
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs
new file mode 100644
index 0000000..32ce7dc
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs
@@ -0,0 +1,9 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Core.Abstractions
+{
+ public interface IAuthorityInvariant
+ {
+ AuthorizationResult Decide(AuthContext context);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs
new file mode 100644
index 0000000..235ea3d
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs
@@ -0,0 +1,10 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Core.Abstractions
+{
+ public interface IAuthorityPolicy
+ {
+ bool AppliesTo(AuthContext context);
+ AuthorizationResult Decide(AuthContext context);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs
index ce4905a..a624091 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs
@@ -6,6 +6,6 @@
///
public interface IClock
{
- DateTime UtcNow { get; }
+ DateTimeOffset UtcNow { get; }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IRefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IRefreshTokenResolver.cs
new file mode 100644
index 0000000..0052345
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IRefreshTokenResolver.cs
@@ -0,0 +1,10 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Core.Abstractions
+{
+ public interface IRefreshTokenResolver
+ {
+ Task?> ResolveAsync(string? tenantId, string refreshToken, DateTimeOffset now, CancellationToken ct = default);
+ }
+
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs
index 726bbfb..4dcb392 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs
@@ -3,11 +3,18 @@
namespace CodeBeam.UltimateAuth.Core.Abstractions
{
- ///
- /// Issues and manages authentication sessions.
- ///
public interface ISessionIssuer
{
- Task> IssueAsync(AuthenticatedSessionContext context, ISessionChain chain, CancellationToken cancellationToken = default);
+ Task> IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default);
+
+ Task> RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default);
+
+ Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken cancellationToken = default);
+
+ Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken cancellationToken = default);
+
+ Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default);
+
+ Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at,CancellationToken ct = default);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs
index 25ad4f5..73228f1 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs
@@ -9,7 +9,7 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions
/// The type used to uniquely identify the user.
public interface IUAuthSessionService
{
- Task> ValidateSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at);
+ Task> ValidateSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at);
Task>> GetChainsAsync(string? tenantId, TUserId userId);
@@ -17,16 +17,16 @@ public interface IUAuthSessionService
Task?> GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId);
- Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at);
+ Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at);
- Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at);
+ Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at);
Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId);
- Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTime at);
+ Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at);
// Hard revoke - admin
- Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at);
+ Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at);
Task> IssueSessionAfterAuthenticationAsync(string? tenantId, AuthenticatedSessionContext context, CancellationToken cancellationToken = default);
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs
index 5b65e7d..8126aca 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs
@@ -37,7 +37,7 @@ Task RotateSessionAsync(
Task RevokeSessionAsync(
string? tenantId,
AuthSessionId sessionId,
- DateTime at);
+ DateTimeOffset at);
///
/// Revokes all sessions for a specific user (all devices).
@@ -45,7 +45,7 @@ Task RevokeSessionAsync(
Task RevokeAllSessionsAsync(
string? tenantId,
TUserId userId,
- DateTime at);
+ DateTimeOffset at);
///
/// Revokes all sessions within a specific chain (single device).
@@ -53,6 +53,6 @@ Task RevokeAllSessionsAsync(
Task RevokeChainAsync(
string? tenantId,
ChainId chainId,
- DateTime at);
+ DateTimeOffset at);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs
index d398eb1..34e9afe 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs
@@ -8,6 +8,12 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions
///
public interface ISessionStoreKernel
{
+ ///
+ /// Executes multiple store operations as a single atomic unit.
+ /// Implementations must ensure transactional consistency where supported.
+ ///
+ Task ExecuteAsync(Func action);
+
///
/// Retrieves a session by its identifier within the given tenant context.
///
@@ -31,7 +37,7 @@ public interface ISessionStoreKernel
/// The tenant identifier, or null.
/// The session identifier.
/// The UTC timestamp of revocation.
- Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at);
+ Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at);
///
/// Returns all sessions belonging to the specified chain, ordered according to store implementation rules.
@@ -62,7 +68,7 @@ public interface ISessionStoreKernel
/// The tenant identifier, or null.
/// The chain to revoke.
/// The UTC timestamp of revocation.
- Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at);
+ Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at);
///
/// Retrieves the active session identifier for the specified chain.
@@ -112,14 +118,14 @@ public interface ISessionStoreKernel
/// The tenant identifier, or null.
/// The user whose root should be revoked.
/// The UTC timestamp of revocation.
- Task RevokeSessionRootAsync(string? tenantId, TUserId userId, DateTime at);
+ Task RevokeSessionRootAsync(string? tenantId, TUserId userId, DateTimeOffset at);
///
/// Removes expired sessions from the store while leaving chains and session roots intact. Cleanup strategy is determined by the store implementation.
///
/// The tenant identifier, or null.
/// The current UTC timestamp.
- Task DeleteExpiredSessionsAsync(string? tenantId, DateTime now);
+ Task DeleteExpiredSessionsAsync(string? tenantId, DateTimeOffset at);
///
/// Retrieves the chain identifier associated with the specified session.
@@ -128,11 +134,5 @@ public interface ISessionStoreKernel
/// The session identifier.
/// The chain identifier or null.
Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId);
-
- ///
- /// Executes multiple store operations as a single atomic unit.
- /// Implementations must ensure transactional consistency where supported.
- ///
- Task ExecuteAsync(Func action);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs
index ee116ac..d414189 100644
--- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStore.cs
@@ -1,4 +1,5 @@
-using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
namespace CodeBeam.UltimateAuth.Core.Abstractions
{
@@ -22,11 +23,11 @@ Task StoreRefreshTokenAsync(
/// Validates a provided refresh token against the stored hash.
/// Returns true if valid and not expired or revoked.
///
- Task ValidateRefreshTokenAsync(
+ Task> ValidateRefreshTokenAsync(
string? tenantId,
- TUserId userId,
- AuthSessionId sessionId,
- string providedRefreshToken);
+ string providedRefreshToken,
+ DateTimeOffset now);
+
///
/// Revokes the refresh token associated with the specified session.
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreFactory.cs
new file mode 100644
index 0000000..4e3fdef
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreFactory.cs
@@ -0,0 +1,7 @@
+namespace CodeBeam.UltimateAuth.Core.Abstractions
+{
+ public interface ITokenStoreFactory
+ {
+ ITokenStoreKernel Create(string? tenantId);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreKernel.cs
new file mode 100644
index 0000000..d708c84
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreKernel.cs
@@ -0,0 +1,47 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Abstractions
+{
+ ///
+ /// Low-level persistence abstraction for token-related data.
+ /// Handles refresh tokens and optional access token identifiers (jti).
+ ///
+ public interface ITokenStoreKernel
+ {
+ Task SaveRefreshTokenAsync(
+ string? tenantId,
+ StoredRefreshToken token);
+
+ Task GetRefreshTokenAsync(
+ string? tenantId,
+ string tokenHash);
+
+ Task RevokeRefreshTokenAsync(
+ string? tenantId,
+ string tokenHash,
+ DateTimeOffset at);
+
+ Task RevokeAllRefreshTokensAsync(
+ string? tenantId,
+ string? userId,
+ DateTimeOffset at);
+
+ Task DeleteExpiredRefreshTokensAsync(
+ string? tenantId,
+ DateTimeOffset now);
+
+ Task StoreTokenIdAsync(
+ string? tenantId,
+ string jti,
+ DateTimeOffset expiresAt);
+
+ Task IsTokenIdRevokedAsync(
+ string? tenantId,
+ string jti);
+
+ Task RevokeTokenIdAsync(
+ string? tenantId,
+ string jti,
+ DateTimeOffset at);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs
new file mode 100644
index 0000000..bce952e
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs
@@ -0,0 +1,61 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public sealed record AuthContext
+ {
+ public string? TenantId { get; init; }
+
+ public AuthOperation Operation { get; init; }
+
+ public UAuthMode Mode { get; init; }
+
+ public SessionAccessContext? Session { get; init; }
+
+ public DeviceContext Device { get; init; }
+
+ public DateTimeOffset At { get; init; }
+
+ private AuthContext() { }
+
+ public static AuthContext System(string? tenantId, AuthOperation operation, DateTimeOffset at, UAuthMode mode = UAuthMode.Hybrid)
+ {
+ return new AuthContext
+ {
+ TenantId = tenantId,
+ Operation = operation,
+ Mode = mode,
+ At = at,
+ Session = null,
+ Device = null
+ };
+ }
+
+ public static AuthContext ForAuthenticatedUser(string? tenantId, AuthOperation operation, DateTimeOffset at, DeviceContext device, UAuthMode mode = UAuthMode.Hybrid)
+ {
+ return new AuthContext
+ {
+ TenantId = tenantId,
+ Operation = operation,
+ Mode = mode,
+ At = at,
+ Device = device,
+ Session = null
+ };
+ }
+
+ public static AuthContext ForSession(string? tenantId, AuthOperation operation, SessionAccessContext session, DateTimeOffset at,
+ DeviceContext device, UAuthMode mode = UAuthMode.Hybrid)
+ {
+ return new AuthContext
+ {
+ TenantId = tenantId,
+ Operation = operation,
+ Mode = mode,
+ At = at,
+ Session = session,
+ Device = device
+ };
+ }
+
+
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs
new file mode 100644
index 0000000..8f41f0d
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs
@@ -0,0 +1,12 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public enum AuthOperation
+ {
+ Login,
+ Access,
+ Refresh,
+ Revoke,
+ Logout,
+ System
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs
new file mode 100644
index 0000000..80d7102
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs
@@ -0,0 +1,10 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public enum AuthorizationDecision
+ {
+ Allow,
+ Deny,
+ Challenge
+ }
+
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationResult.cs
new file mode 100644
index 0000000..09af255
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationResult.cs
@@ -0,0 +1,29 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public sealed class AuthorizationResult
+ {
+ public AuthorizationDecision Decision { get; }
+ public string? Reason { get; }
+
+ private AuthorizationResult(AuthorizationDecision decision, string? reason)
+ {
+ Decision = decision;
+ Reason = reason;
+ }
+
+ public static AuthorizationResult Allow()
+ => new(AuthorizationDecision.Allow, null);
+
+ public static AuthorizationResult Deny(string reason)
+ => new(AuthorizationDecision.Deny, reason);
+
+ public static AuthorizationResult Challenge(string reason)
+ => new(AuthorizationDecision.Challenge, reason);
+
+ // Developer happiness helpers
+ public bool IsAllowed => Decision == AuthorizationDecision.Allow;
+ public bool IsDenied => Decision == AuthorizationDecision.Deny;
+ public bool RequiresChallenge => Decision == AuthorizationDecision.Challenge;
+ }
+
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs
new file mode 100644
index 0000000..8cbdeff
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs
@@ -0,0 +1,28 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public sealed record DeviceContext
+ {
+ public string DeviceId { get; init; } = default!;
+
+ public bool IsKnownDevice { get; init; }
+
+ public bool IsTrusted { get; init; }
+
+ public string? Platform { get; init; }
+
+ public string? UserAgent { get; init; }
+
+ public static DeviceContext From(DeviceInfo info)
+ {
+ return new DeviceContext
+ {
+ DeviceId = info.DeviceId,
+ Platform = info.Platform,
+ UserAgent = info.UserAgent
+ };
+ }
+ }
+
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/SessionAccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/SessionAccessContext.cs
new file mode 100644
index 0000000..4a32bf3
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/SessionAccessContext.cs
@@ -0,0 +1,18 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public sealed record SessionAccessContext
+ {
+ public SessionState State { get; init; }
+
+ public bool IsExpired { get; init; }
+
+ public bool IsRevoked { get; init; }
+
+ public string? ChainId { get; init; }
+
+ public string? BoundDeviceId { get; init; }
+ }
+
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs
index 97a75f0..98f3895 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs
@@ -7,7 +7,7 @@ public sealed record LoginRequest
public string? TenantId { get; init; }
public string Identifier { get; init; } = default!; // username, email etc.
public string Secret { get; init; } = default!; // password
- public DateTime? At { get; init; }
+ public DateTimeOffset? At { get; init; }
public DeviceInfo DeviceInfo { get; init; }
public IReadOnlyDictionary? Metadata { get; init; }
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs
index 7bfa2da..2aa6b6a 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs
@@ -17,7 +17,7 @@ public sealed class LogoutAllRequest
///
public bool ExceptCurrent { get; init; }
- public DateTime? At { get; init; }
+ public DateTimeOffset? At { get; init; }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs
index 90e6973..7229f0a 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs
@@ -7,10 +7,6 @@ public sealed record LogoutRequest
public string? TenantId { get; init; }
public AuthSessionId SessionId { get; init; }
- ///
- /// Optional logical timestamp for the logout operation.
- /// If not provided, the flow service will use DateTime.UtcNow.
- ///
- public DateTime? At { get; init; }
+ public DateTimeOffset? At { get; init; }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs
index 6422fbb..a46570e 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs
@@ -11,7 +11,7 @@ public sealed class AuthenticatedSessionContext
public string? TenantId { get; init; }
public required TUserId UserId { get; init; }
public DeviceInfo DeviceInfo { get; init; }
- public DateTime Now { get; init; }
+ public DateTimeOffset Now { get; init; }
public ClaimsSnapshot? Claims { get; init; }
public SessionMetadata Metadata { get; init; }
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs
new file mode 100644
index 0000000..9189664
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs
@@ -0,0 +1,38 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public sealed record ResolvedRefreshSession
+ {
+ public bool IsValid { get; init; }
+ public bool IsReuseDetected { get; init; }
+
+ public ISession? Session { get; init; }
+ public ISessionChain? Chain { get; init; }
+
+ private ResolvedRefreshSession() { }
+
+ public static ResolvedRefreshSession Invalid()
+ => new()
+ {
+ IsValid = false
+ };
+
+ public static ResolvedRefreshSession Reused()
+ => new()
+ {
+ IsValid = false,
+ IsReuseDetected = true
+ };
+
+ public static ResolvedRefreshSession Valid(
+ ISession session,
+ ISessionChain chain)
+ => new()
+ {
+ IsValid = true,
+ Session = session,
+ Chain = chain
+ };
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs
index d207f59..f3f7d93 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs
@@ -1,10 +1,21 @@
-using CodeBeam.UltimateAuth.Core.Contracts;
-
-namespace CodeBeam.UltimateAuth.Core.Contracts
+namespace CodeBeam.UltimateAuth.Core.Contracts
{
public sealed record SessionRefreshResult
{
public AccessToken AccessToken { get; init; } = default!;
public RefreshToken? RefreshToken { get; init; }
+
+ public bool IsValid => AccessToken is not null;
+
+ private SessionRefreshResult() { }
+
+ public static SessionRefreshResult Success(AccessToken accessToken, RefreshToken? refreshToken)
+ => new()
+ {
+ AccessToken = accessToken,
+ RefreshToken = refreshToken
+ };
+
+ public static SessionRefreshResult Invalid() => new();
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs
index 2510afa..874e4b7 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs
@@ -7,7 +7,7 @@ public sealed record SessionRotationContext
public string? TenantId { get; init; }
public AuthSessionId CurrentSessionId { get; init; }
public TUserId UserId { get; init; }
- public DateTime Now { get; init; }
+ public DateTimeOffset Now { get; init; }
public DeviceInfo Device { get; init; }
public ClaimsSnapshot Claims { get; init; }
public SessionMetadata Metadata { get; init; }
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs
index aa8b3dd..85a3996 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs
@@ -6,7 +6,7 @@ public sealed record SessionValidationContext
{
public string? TenantId { get; init; }
public AuthSessionId SessionId { get; init; }
- public DateTime Now { get; init; }
+ public DateTimeOffset Now { get; init; }
public DeviceInfo Device { get; init; }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenFailureReason.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenFailureReason.cs
new file mode 100644
index 0000000..8e4cefc
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenFailureReason.cs
@@ -0,0 +1,10 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public enum RefreshTokenFailureReason
+ {
+ Invalid,
+ Expired,
+ Revoked,
+ Reused
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs
new file mode 100644
index 0000000..68641ef
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs
@@ -0,0 +1,31 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public sealed record RefreshTokenValidationResult
+ {
+ public bool IsValid { get; init; }
+
+ public TUserId? UserId { get; init; }
+
+ public AuthSessionId? SessionId { get; init; }
+
+ private RefreshTokenValidationResult() { }
+
+ public static RefreshTokenValidationResult Invalid()
+ => new()
+ {
+ IsValid = false
+ };
+
+ public static RefreshTokenValidationResult Valid(
+ TUserId userId,
+ AuthSessionId sessionId)
+ => new()
+ {
+ IsValid = true,
+ UserId = userId,
+ SessionId = sessionId
+ };
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs
index ad3dc76..c4cb22f 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs
@@ -6,6 +6,6 @@ public sealed record TokenIssueContext
{
public string? TenantId { get; init; }
public ISession Session { get; init; } = default!;
- public DateTime Now { get; init; }
+ public DateTimeOffset At { get; init; }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs
index b8e7c29..1548552 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs
@@ -12,7 +12,7 @@ public sealed record TokenValidationResult
public AuthSessionId? SessionId { get; init; }
public IReadOnlyCollection Claims { get; init; } = Array.Empty();
public TokenInvalidReason? InvalidReason { get; init; }
- public DateTime? ExpiresAt { get; set; }
+ public DateTimeOffset? ExpiresAt { get; set; }
private TokenValidationResult(
bool isValid,
@@ -22,7 +22,7 @@ private TokenValidationResult(
AuthSessionId? sessionId,
IReadOnlyCollection? claims,
TokenInvalidReason? invalidReason,
- DateTime? expiresAt
+ DateTimeOffset? expiresAt
)
{
IsValid = isValid;
@@ -40,7 +40,7 @@ public static TokenValidationResult Valid(
TUserId userId,
AuthSessionId? sessionId,
IReadOnlyCollection claims,
- DateTime? expiresAt)
+ DateTimeOffset? expiresAt)
=> new(
isValid: true,
type,
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Unit.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Unit.cs
new file mode 100644
index 0000000..e921add
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Unit.cs
@@ -0,0 +1,7 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts
+{
+ public readonly struct Unit
+ {
+ public static readonly Unit Value = new();
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs
index 967032b..292898b 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ChainId.cs
@@ -41,6 +41,10 @@ public ChainId(Guid value)
/// true if the object is a with the same value.
public override bool Equals(object? obj) => obj is ChainId other && Equals(other);
+ public static bool operator ==(ChainId left, ChainId right) => left.Equals(right);
+
+ public static bool operator !=(ChainId left, ChainId right) => !left.Equals(right);
+
///
/// Returns a hash code based on the underlying GUID value.
///
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs
index 232e13a..1fcb845 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs
@@ -12,26 +12,30 @@ public interface ISession
///
AuthSessionId SessionId { get; }
+ string? TenantId { get; }
+
///
/// Gets the identifier of the user who owns this session.
///
TUserId UserId { get; }
+ ChainId ChainId { get; }
+
///
/// Gets the timestamp when this session was originally created.
///
- DateTime CreatedAt { get; }
+ DateTimeOffset CreatedAt { get; }
///
/// Gets the timestamp when the session becomes invalid due to expiration.
///
- DateTime ExpiresAt { get; }
+ DateTimeOffset ExpiresAt { get; }
///
/// Gets the timestamp of the last successful usage.
/// Used when evaluating sliding expiration policies.
///
- DateTime? LastSeenAt { get; }
+ DateTimeOffset? LastSeenAt { get; }
///
/// Gets a value indicating whether this session has been explicitly revoked.
@@ -41,7 +45,7 @@ public interface ISession
///
/// Gets the timestamp when the session was revoked, if applicable.
///
- DateTime? RevokedAt { get; }
+ DateTimeOffset? RevokedAt { get; }
///
/// Gets the user's security version at the moment of session creation.
@@ -70,10 +74,10 @@ public interface ISession
///
/// Current timestamp used for comparisons.
/// The evaluated of this session.
- SessionState GetState(DateTime now);
+ SessionState GetState(DateTimeOffset now);
- bool ShouldUpdateLastSeen(DateTime now);
- ISession Touch(DateTime now);
+ bool ShouldUpdateLastSeen(DateTimeOffset now);
+ ISession Touch(DateTimeOffset now);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs
index 358beb8..da2b8d8 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs
@@ -52,11 +52,11 @@ public interface ISessionChain
///
/// Gets the timestamp when the chain was revoked, if applicable.
///
- DateTime? RevokedAt { get; }
+ DateTimeOffset? RevokedAt { get; }
ISessionChain AttachSession(AuthSessionId sessionId);
ISessionChain RotateSession(AuthSessionId sessionId);
- ISessionChain Revoke(DateTime at);
+ ISessionChain Revoke(DateTimeOffset at);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs
index afc7d14..c51ba8e 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionRoot.cs
@@ -29,7 +29,7 @@ public interface ISessionRoot
///
/// Gets the timestamp when the session root was revoked, if applicable.
///
- DateTime? RevokedAt { get; }
+ DateTimeOffset? RevokedAt { get; }
///
/// Gets the current security version of the user within this tenant.
@@ -49,6 +49,10 @@ public interface ISessionRoot
/// Gets the timestamp when this root structure was last updated.
/// Useful for caching, concurrency handling, and incremental synchronization.
///
- DateTime LastUpdatedAt { get; }
+ DateTimeOffset LastUpdatedAt { get; }
+
+ ISessionRoot AttachChain(ISessionChain chain, DateTimeOffset at);
+
+ ISessionRoot Revoke(DateTimeOffset at);
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs
index a56fcd2..c3c0d42 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs
@@ -5,11 +5,12 @@ public sealed class UAuthSession : ISession
public AuthSessionId SessionId { get; }
public string? TenantId { get; }
public TUserId UserId { get; }
- public DateTime CreatedAt { get; }
- public DateTime ExpiresAt { get; }
- public DateTime? LastSeenAt { get; }
+ public ChainId ChainId { get; }
+ public DateTimeOffset CreatedAt { get; }
+ public DateTimeOffset ExpiresAt { get; }
+ public DateTimeOffset? LastSeenAt { get; }
public bool IsRevoked { get; }
- public DateTime? RevokedAt { get; }
+ public DateTimeOffset? RevokedAt { get; }
public long SecurityVersionAtCreation { get; }
public DeviceInfo Device { get; }
public ClaimsSnapshot Claims { get; }
@@ -19,11 +20,12 @@ private UAuthSession(
AuthSessionId sessionId,
string? tenantId,
TUserId userId,
- DateTime createdAt,
- DateTime expiresAt,
- DateTime? lastSeenAt,
+ ChainId chainId,
+ DateTimeOffset createdAt,
+ DateTimeOffset expiresAt,
+ DateTimeOffset? lastSeenAt,
bool isRevoked,
- DateTime? revokedAt,
+ DateTimeOffset? revokedAt,
long securityVersionAtCreation,
DeviceInfo device,
ClaimsSnapshot claims,
@@ -32,6 +34,7 @@ private UAuthSession(
SessionId = sessionId;
TenantId = tenantId;
UserId = userId;
+ ChainId = chainId;
CreatedAt = createdAt;
ExpiresAt = expiresAt;
LastSeenAt = lastSeenAt;
@@ -47,8 +50,9 @@ public static UAuthSession Create(
AuthSessionId sessionId,
string? tenantId,
TUserId userId,
- DateTime now,
- DateTime expiresAt,
+ ChainId chainId,
+ DateTimeOffset now,
+ DateTimeOffset expiresAt,
DeviceInfo device,
ClaimsSnapshot claims,
SessionMetadata metadata)
@@ -57,6 +61,7 @@ public static UAuthSession Create(
sessionId,
tenantId,
userId,
+ chainId,
createdAt: now,
expiresAt: expiresAt,
lastSeenAt: now,
@@ -78,6 +83,7 @@ public UAuthSession WithSecurityVersion(long version)
SessionId,
TenantId,
UserId,
+ ChainId,
CreatedAt,
ExpiresAt,
LastSeenAt,
@@ -90,26 +96,27 @@ public UAuthSession WithSecurityVersion(long version)
);
}
- public bool ShouldUpdateLastSeen(DateTime now)
+ public bool ShouldUpdateLastSeen(DateTimeOffset at)
{
if (LastSeenAt is null)
return true;
- return (now - LastSeenAt.Value) >= TimeSpan.FromMinutes(1);
+ return (at - LastSeenAt.Value) >= TimeSpan.FromMinutes(1);
}
- public ISession Touch(DateTime now)
+ public ISession Touch(DateTimeOffset at)
{
- if (!ShouldUpdateLastSeen(now))
+ if (!ShouldUpdateLastSeen(at))
return this;
return new UAuthSession(
SessionId,
TenantId,
UserId,
+ ChainId,
CreatedAt,
ExpiresAt,
- now,
+ at,
IsRevoked,
RevokedAt,
SecurityVersionAtCreation,
@@ -119,7 +126,7 @@ public ISession Touch(DateTime now)
);
}
- public UAuthSession Revoke(DateTime at)
+ public UAuthSession Revoke(DateTimeOffset at)
{
if (IsRevoked) return this;
@@ -127,6 +134,7 @@ public UAuthSession Revoke(DateTime at)
SessionId,
TenantId,
UserId,
+ ChainId,
CreatedAt,
ExpiresAt,
LastSeenAt,
@@ -139,10 +147,10 @@ public UAuthSession Revoke(DateTime at)
);
}
- public SessionState GetState(DateTime now)
+ public SessionState GetState(DateTimeOffset at)
{
if (IsRevoked) return SessionState.Revoked;
- if (now >= ExpiresAt) return SessionState.Expired;
+ if (at >= ExpiresAt) return SessionState.Expired;
return SessionState.Active;
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs
index 1afa3aa..70849ea 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs
@@ -10,7 +10,7 @@ public sealed class UAuthSessionChain : ISessionChain
public ClaimsSnapshot ClaimsSnapshot { get; }
public AuthSessionId? ActiveSessionId { get; }
public bool IsRevoked { get; }
- public DateTime? RevokedAt { get; }
+ public DateTimeOffset? RevokedAt { get; }
private UAuthSessionChain(
ChainId chainId,
@@ -21,7 +21,7 @@ private UAuthSessionChain(
ClaimsSnapshot claimsSnapshot,
AuthSessionId? activeSessionId,
bool isRevoked,
- DateTime? revokedAt)
+ DateTimeOffset? revokedAt)
{
ChainId = chainId;
TenantId = tenantId;
@@ -90,7 +90,7 @@ public ISessionChain RotateSession(AuthSessionId sessionId)
);
}
- public ISessionChain Revoke(DateTime at)
+ public ISessionChain Revoke(DateTimeOffset at)
{
if (IsRevoked)
return this;
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs
index 0b0be80..1f6641f 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs
@@ -5,19 +5,19 @@ public sealed class UAuthSessionRoot : ISessionRoot
public TUserId UserId { get; }
public string? TenantId { get; }
public bool IsRevoked { get; }
- public DateTime? RevokedAt { get; }
+ public DateTimeOffset? RevokedAt { get; }
public long SecurityVersion { get; }
public IReadOnlyList> Chains { get; }
- public DateTime LastUpdatedAt { get; }
+ public DateTimeOffset LastUpdatedAt { get; }
private UAuthSessionRoot(
string? tenantId,
TUserId userId,
bool isRevoked,
- DateTime? revokedAt,
+ DateTimeOffset? revokedAt,
long securityVersion,
IReadOnlyList> chains,
- DateTime lastUpdatedAt)
+ DateTimeOffset lastUpdatedAt)
{
TenantId = tenantId;
UserId = userId;
@@ -28,10 +28,10 @@ private UAuthSessionRoot(
LastUpdatedAt = lastUpdatedAt;
}
- public static UAuthSessionRoot Create(
+ public static ISessionRoot Create(
string? tenantId,
TUserId userId,
- DateTime issuedAt)
+ DateTimeOffset issuedAt)
{
return new UAuthSessionRoot(
tenantId,
@@ -44,7 +44,7 @@ public static UAuthSessionRoot Create(
);
}
- public UAuthSessionRoot Revoke(DateTime at)
+ public ISessionRoot Revoke(DateTimeOffset at)
{
if (IsRevoked)
return this;
@@ -60,5 +60,21 @@ public UAuthSessionRoot Revoke(DateTime at)
);
}
+ public ISessionRoot AttachChain(ISessionChain chain, DateTimeOffset at)
+ {
+ if (IsRevoked)
+ return this;
+
+ return new UAuthSessionRoot(
+ TenantId,
+ UserId,
+ IsRevoked,
+ RevokedAt,
+ SecurityVersion,
+ Chains.Concat(new[] { chain }).ToArray(),
+ at
+ );
+ }
+
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs
new file mode 100644
index 0000000..fd91893
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs
@@ -0,0 +1,19 @@
+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
+ {
+ public string TokenHash { get; init; } = default!;
+
+ public AuthSessionId SessionId { get; init; } = default!;
+
+ public DateTimeOffset ExpiresAt { get; init; }
+
+ public DateTimeOffset? RevokedAt { get; init; }
+
+ public bool IsRevoked => RevokedAt.HasValue;
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs
index 84bc03c..c272e50 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs
@@ -13,7 +13,7 @@ public sealed class UAuthJwtTokenDescriptor
public required string Audience { get; init; }
- public required DateTime Expires { get; init; }
+ public required DateTimeOffset Expires { get; init; }
///
/// Signing key material (symmetric or asymmetric).
diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs
new file mode 100644
index 0000000..e927d41
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs
@@ -0,0 +1,10 @@
+namespace CodeBeam.UltimateAuth.Core.Errors
+{
+ public sealed class UAuthChallengeRequiredException : UAuthException
+ {
+ public UAuthChallengeRequiredException(string? reason = null)
+ : base(reason ?? "Additional authentication is required to perform this operation.")
+ {
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs
new file mode 100644
index 0000000..f7bbf0e
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs
@@ -0,0 +1,10 @@
+namespace CodeBeam.UltimateAuth.Core.Errors
+{
+ public sealed class UAuthAuthorizationException : UAuthException
+ {
+ public UAuthAuthorizationException(string? reason = null)
+ : base(reason ?? "The current principal is not authorized to perform this operation.")
+ {
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs
index 532c86a..a989b86 100644
--- a/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionCreatedContext.cs
@@ -32,12 +32,12 @@ public sealed class SessionCreatedContext : IAuthEventContext
///
/// Gets the timestamp on which the session was created.
///
- public DateTime CreatedAt { get; }
+ public DateTimeOffset CreatedAt { get; }
///
/// Initializes a new instance of the class.
///
- public SessionCreatedContext(TUserId userId, AuthSessionId sessionId, ChainId chainId, DateTime createdAt)
+ public SessionCreatedContext(TUserId userId, AuthSessionId sessionId, ChainId chainId, DateTimeOffset createdAt)
{
UserId = userId;
SessionId = sessionId;
diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs
index 85ab19e..0c0ce5f 100644
--- a/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionRefreshedContext.cs
@@ -38,7 +38,7 @@ public sealed class SessionRefreshedContext : IAuthEventContext
///
/// Gets the timestamp at which the refresh occurred.
///
- public DateTime RefreshedAt { get; }
+ public DateTimeOffset RefreshedAt { get; }
///
/// Initializes a new instance of the class.
@@ -48,7 +48,7 @@ public SessionRefreshedContext(
AuthSessionId oldSessionId,
AuthSessionId newSessionId,
ChainId chainId,
- DateTime refreshedAt)
+ DateTimeOffset refreshedAt)
{
UserId = userId;
OldSessionId = oldSessionId;
diff --git a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs
index bd19b7e..ee7e98a 100644
--- a/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Events/SessionRevokedContext.cs
@@ -36,7 +36,7 @@ public sealed class SessionRevokedContext : IAuthEventContext
///
/// Gets the timestamp at which the session revocation occurred.
///
- public DateTime RevokedAt { get; }
+ public DateTimeOffset RevokedAt { get; }
///
/// Initializes a new instance of the class.
@@ -45,7 +45,7 @@ public SessionRevokedContext(
TUserId userId,
AuthSessionId sessionId,
ChainId chainId,
- DateTime revokedAt)
+ DateTimeOffset revokedAt)
{
UserId = userId;
SessionId = sessionId;
diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs
index f6f4af0..b661db7 100644
--- a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs
@@ -28,12 +28,12 @@ public sealed class UserLoggedInContext : IAuthEventContext
///
/// Gets the timestamp at which the login event occurred.
///
- public DateTime LoggedInAt { get; }
+ public DateTimeOffset LoggedInAt { get; }
///
/// Initializes a new instance of the class.
///
- public UserLoggedInContext(TUserId userId, DateTime at)
+ public UserLoggedInContext(TUserId userId, DateTimeOffset at)
{
UserId = userId;
LoggedInAt = at;
diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs
index 311a87c..6f6e707 100644
--- a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs
@@ -27,12 +27,12 @@ public sealed class UserLoggedOutContext : IAuthEventContext
///
/// Gets the timestamp at which the logout occurred.
///
- public DateTime LoggedOutAt { get; }
+ public DateTimeOffset LoggedOutAt { get; }
///
/// Initializes a new instance of the class.
///
- public UserLoggedOutContext(TUserId userId, DateTime at)
+ public UserLoggedOutContext(TUserId userId, DateTimeOffset at)
{
UserId = userId;
LoggedOutAt = at;
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs
new file mode 100644
index 0000000..4b5ca72
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs
@@ -0,0 +1,43 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Core.Infrastructure
+{
+ public sealed class DefaultAuthAuthority : IAuthAuthority
+ {
+ private readonly IEnumerable _invariants;
+ private readonly IEnumerable _policies;
+
+ public AuthorizationResult Decide(AuthContext context)
+ {
+ // 1. Invariants
+ foreach (var invariant in _invariants)
+ {
+ var result = invariant.Decide(context);
+ if (!result.IsAllowed)
+ return result;
+ }
+
+ // 2. Policies
+ bool challenged = false;
+
+ foreach (var policy in _policies)
+ {
+ if (!policy.AppliesTo(context))
+ continue;
+
+ var result = policy.Decide(context);
+
+ if (!result.IsAllowed)
+ return result;
+
+ if (result.RequiresChallenge)
+ challenged = true;
+ }
+
+ return challenged
+ ? AuthorizationResult.Challenge("Additional verification required.")
+ : AuthorizationResult.Allow();
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs
new file mode 100644
index 0000000..8bc3abc
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs
@@ -0,0 +1,32 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Core.Infrastructure
+{
+ public sealed class DeviceTrustPolicy : IAuthorityPolicy
+ {
+ public bool AppliesTo(AuthContext context) => context.Device is not null;
+
+ public AuthorizationResult Decide(AuthContext context)
+ {
+ var device = context.Device;
+
+ if (device.IsTrusted)
+ return AuthorizationResult.Allow();
+
+ return context.Operation switch
+ {
+ AuthOperation.Login =>
+ AuthorizationResult.Challenge("Login from untrusted device requires additional verification."),
+
+ AuthOperation.Refresh =>
+ AuthorizationResult.Challenge("Token refresh from untrusted device requires additional verification."),
+
+ AuthOperation.Access =>
+ AuthorizationResult.Deny("Access from untrusted device is not allowed."),
+
+ _ => AuthorizationResult.Allow()
+ };
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs
new file mode 100644
index 0000000..a3eedf6
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs
@@ -0,0 +1,27 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Infrastructure
+{
+ public sealed class ExpiredSessionInvariant : IAuthorityInvariant
+ {
+ public AuthorizationResult Decide(AuthContext context)
+ {
+ if (context.Operation == AuthOperation.Login)
+ return AuthorizationResult.Allow();
+
+ var session = context.Session;
+
+ if (session is null)
+ return AuthorizationResult.Allow();
+
+ if (session.State == SessionState.Expired)
+ {
+ return AuthorizationResult.Deny("Session has expired.");
+ }
+
+ return AuthorizationResult.Allow();
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs
new file mode 100644
index 0000000..1929bb5
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs
@@ -0,0 +1,31 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Core.Infrastructure
+{
+ public sealed class InvalidOrRevokedSessionInvariant : IAuthorityInvariant
+ {
+ public AuthorizationResult Decide(AuthContext context)
+ {
+ if (context.Operation == AuthOperation.Login)
+ return AuthorizationResult.Allow();
+
+ var session = context.Session;
+
+ if (session is null)
+ return AuthorizationResult.Deny("Session is required for this operation.");
+
+ if (session.State == SessionState.Invalid ||
+ session.State == SessionState.NotFound ||
+ session.State == SessionState.Revoked ||
+ session.State == SessionState.SecurityMismatch ||
+ session.State == SessionState.DeviceMismatch)
+ {
+ return AuthorizationResult.Deny($"Session state is invalid: {session.State}");
+ }
+
+ return AuthorizationResult.Allow();
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs
new file mode 100644
index 0000000..5096041
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs
@@ -0,0 +1,39 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Core.Infrastructure
+{
+ public sealed class AuthModeOperationPolicy : IAuthorityPolicy
+ {
+ public bool AppliesTo(AuthContext context) => true; // Applies to all contexts
+
+ public AuthorizationResult Decide(AuthContext context)
+ {
+ return context.Mode switch
+ {
+ UAuthMode.PureOpaque => DecideForPureOpaque(context),
+ UAuthMode.PureJwt => DecideForPureJwt(context),
+ UAuthMode.Hybrid => AuthorizationResult.Allow(),
+ UAuthMode.SemiHybrid => AuthorizationResult.Allow(),
+
+ _ => AuthorizationResult.Deny("Unsupported authentication mode.")
+ };
+ }
+
+ private static AuthorizationResult DecideForPureOpaque(AuthContext context)
+ {
+ if (context.Operation == AuthOperation.Refresh)
+ return AuthorizationResult.Deny("Refresh operation is not supported in PureOpaque mode.");
+
+ return AuthorizationResult.Allow();
+ }
+
+ private static AuthorizationResult DecideForPureJwt(AuthContext context)
+ {
+ if (context.Operation == AuthOperation.Access)
+ return AuthorizationResult.Deny("Session-based access is not supported in PureJwt mode.");
+
+ return AuthorizationResult.Allow();
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs
new file mode 100644
index 0000000..cbbbfc2
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs
@@ -0,0 +1,83 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Core.Infrastructure
+{
+ internal sealed class StoreRefreshTokenResolver
+ : IRefreshTokenResolver
+ {
+ private readonly ISessionStoreFactory _sessionStoreFactory;
+ private readonly ITokenStoreFactory _tokenStoreFactory;
+ private readonly ITokenHasher _hasher;
+
+ public StoreRefreshTokenResolver(
+ ISessionStoreFactory sessionStoreFactory,
+ ITokenStoreFactory tokenStoreFactory,
+ ITokenHasher hasher)
+ {
+ _sessionStoreFactory = sessionStoreFactory;
+ _tokenStoreFactory = tokenStoreFactory;
+ _hasher = hasher;
+ }
+
+ public async Task?> ResolveAsync(
+ string? tenantId,
+ string refreshToken,
+ DateTimeOffset now,
+ CancellationToken ct = default)
+ {
+ var tokenHash = _hasher.Hash(refreshToken);
+
+ var tokenStore = _tokenStoreFactory.Create(tenantId);
+ var sessionStore = _sessionStoreFactory.Create(tenantId);
+
+ var stored = await tokenStore.GetRefreshTokenAsync(
+ tenantId,
+ tokenHash);
+
+ if (stored is null)
+ return null;
+
+ if (stored.IsRevoked)
+ {
+ return ResolvedRefreshSession.Reused();
+ }
+
+ if (stored.ExpiresAt <= now)
+ {
+ await tokenStore.RevokeRefreshTokenAsync(
+ tenantId,
+ tokenHash,
+ now);
+
+ return ResolvedRefreshSession.Invalid();
+ }
+
+ var session = await sessionStore.GetSessionAsync(
+ tenantId,
+ stored.SessionId);
+
+ if (session is null)
+ return null;
+
+ if (session.IsRevoked || session.ExpiresAt <= now)
+ return null;
+
+ var chain = await sessionStore.GetChainAsync(
+ tenantId,
+ session.ChainId);
+
+ if (chain is null || chain.IsRevoked)
+ return null;
+
+ await tokenStore.RevokeRefreshTokenAsync(
+ tenantId,
+ tokenHash,
+ now);
+
+ return ResolvedRefreshSession.Valid(
+ session,
+ chain);
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs
index 3f80324..a5ad9a1 100644
--- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs
@@ -10,7 +10,7 @@ public sealed class UserRecord
public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty;
public bool RequiresMfa { get; init; }
public bool IsActive { get; init; } = true;
- public DateTime CreatedAt { get; init; }
+ public DateTimeOffset CreatedAt { get; init; }
public bool IsDeleted { get; init; }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/LoginResponse.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/LoginResponse.cs
new file mode 100644
index 0000000..48656db
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Contracts/LoginResponse.cs
@@ -0,0 +1,12 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Server.Contracts
+{
+ public sealed record LoginResponse
+ {
+ public string? SessionId { get; init; }
+ public AccessToken? AccessToken { get; init; }
+ public RefreshToken? RefreshToken { get; init; }
+ public object? Continuation { get; init; }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/LogoutResponse.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/LogoutResponse.cs
new file mode 100644
index 0000000..2f80422
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Contracts/LogoutResponse.cs
@@ -0,0 +1,7 @@
+namespace CodeBeam.UltimateAuth.Server.Contracts
+{
+ public sealed record LogoutResponse
+ {
+ public bool Success { get; init; }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs
index cb63cef..56c1eac 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs
@@ -1,7 +1,9 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Server.Abstractions;
+using CodeBeam.UltimateAuth.Server.Contracts;
using CodeBeam.UltimateAuth.Server.Endpoints;
+using CodeBeam.UltimateAuth.Server.Extensions;
using CodeBeam.UltimateAuth.Server.MultiTenancy;
using Microsoft.AspNetCore.Http;
@@ -30,7 +32,8 @@ public async Task LoginAsync(HttpContext ctx)
if (request is null)
return Results.BadRequest("Invalid login request.");
- var tenantCtx = await _tenantResolver.ResolveAsync(ctx);
+ // Middleware should have already resolved the tenant
+ var tenantCtx = ctx.GetTenantContext();
var flowRequest = request with
{
@@ -43,18 +46,21 @@ public async Task LoginAsync(HttpContext ctx)
return result.Status switch
{
- LoginStatus.Success => Results.Ok(new
+ LoginStatus.Success => Results.Ok(new LoginResponse
{
- sessionId = result.SessionId,
- accessToken = result.AccessToken,
- refreshToken = result.RefreshToken
+ SessionId = result.SessionId,
+ AccessToken = result.AccessToken,
+ RefreshToken = result.RefreshToken
}),
- LoginStatus.RequiresContinuation => Results.Accepted(null, result.Continuation),
+ LoginStatus.RequiresContinuation => Results.Ok(new LoginResponse
+ {
+ Continuation = result.Continuation
+ }),
LoginStatus.Failed => Results.Unauthorized(),
- _ => Results.StatusCode(500)
+ _ => Results.StatusCode(StatusCodes.Status500InternalServerError)
};
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs
new file mode 100644
index 0000000..8a13f4f
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs
@@ -0,0 +1,43 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Server.Contracts;
+using CodeBeam.UltimateAuth.Server.Extensions;
+using Microsoft.AspNetCore.Http;
+
+namespace CodeBeam.UltimateAuth.Server.Endpoints
+{
+ public sealed class DefaultLogoutEndpointHandler : ILogoutEndpointHandler
+ {
+ private readonly IUAuthFlowService _flow;
+ private readonly IClock _clock;
+
+ public DefaultLogoutEndpointHandler(IUAuthFlowService flow, IClock clock)
+ {
+ _flow = flow;
+ _clock = clock;
+ }
+
+ public async Task LogoutAsync(HttpContext ctx)
+ {
+ var tenantCtx = ctx.GetTenantContext();
+ var sessionCtx = ctx.GetSessionContext();
+
+ if (sessionCtx.IsAnonymous)
+ return Results.Unauthorized();
+
+ var request = new LogoutRequest
+ {
+ TenantId = tenantCtx.TenantId,
+ SessionId = sessionCtx.SessionId!.Value,
+ At = _clock.UtcNow
+ };
+
+ await _flow.LogoutAsync(request, ctx.RequestAborted);
+
+ return Results.Ok(new LogoutResponse
+ {
+ Success = true
+ });
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs
deleted file mode 100644
index b388f55..0000000
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using CodeBeam.UltimateAuth.Core.Contracts;
-using CodeBeam.UltimateAuth.Core.Domain;
-
-namespace CodeBeam.UltimateAuth.Server.Infrastructure
-{
- internal interface ISessionOrchestrator
- {
- Task> CreateLoginSessionAsync(AuthenticatedSessionContext context);
-
- Task> RotateSessionAsync(SessionRotationContext context);
-
- Task> ValidateSessionAsync(SessionValidationContext context);
-
- Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId);
-
- Task>> GetChainsAsync(string? tenantId, TUserId userId);
-
- Task ResolveChainIdAsync(string? tenantId,AuthSessionId sessionId);
-
- Task>> GetSessionsAsync(string? tenantId, ChainId chainId);
-
- Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at);
-
- Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at);
-
- Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId,DateTime at);
-
- Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at);
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs
new file mode 100644
index 0000000..98496b7
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs
@@ -0,0 +1,13 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure
+{
+ internal sealed record CreateLoginSessionCommand(AuthenticatedSessionContext LoginContext) : ISessionCommand>
+ {
+ public Task> ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct)
+ {
+ return issuer.IssueLoginSessionAsync(LoginContext, ct);
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs
new file mode 100644
index 0000000..5139b34
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs
@@ -0,0 +1,10 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure
+{
+ public interface ISessionCommand
+ {
+ Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs
new file mode 100644
index 0000000..8cedd4d
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs
@@ -0,0 +1,8 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+namespace CodeBeam.UltimateAuth.Server.Infrastructure
+{
+ internal interface ISessionOrchestrator
+ {
+ Task ExecuteAsync(AuthContext authContext, ISessionCommand command, CancellationToken ct = default);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs
new file mode 100644
index 0000000..a271c0d
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs
@@ -0,0 +1,18 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure
+{
+ public interface ISessionQueryService
+ {
+ Task> ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default);
+
+ Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default);
+
+ Task>> GetSessionsByChainAsync(string? tenantId, ChainId chainId, CancellationToken ct = default);
+
+ Task>> GetChainsByUserAsync(string? tenantId, TUserId userId, CancellationToken ct = default);
+
+ Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId, CancellationToken ct = default);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs
new file mode 100644
index 0000000..eacdfe7
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs
@@ -0,0 +1,24 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure
+{
+ public sealed class RevokeAllChainsCommand : ISessionCommand
+ {
+ public TUserId UserId { get; }
+ public ChainId? ExceptChainId { get; }
+
+ public RevokeAllChainsCommand(TUserId userId, ChainId? exceptChainId)
+ {
+ UserId = userId;
+ ExceptChainId = exceptChainId;
+ }
+
+ public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct)
+ {
+ await issuer.RevokeAllChainsAsync(context.TenantId, UserId, ExceptChainId, context.At, ct);
+ return Unit.Value;
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs
new file mode 100644
index 0000000..0815f98
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs
@@ -0,0 +1,30 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator
+{
+ public sealed class RevokeChainCommand : ISessionCommand
+ {
+ public ChainId ChainId { get; }
+
+ public RevokeChainCommand(ChainId chainId)
+ {
+ ChainId = chainId;
+ }
+
+ public async Task ExecuteAsync(
+ AuthContext context,
+ ISessionIssuer issuer,
+ CancellationToken ct)
+ {
+ await issuer.RevokeChainAsync(
+ context.TenantId,
+ ChainId,
+ context.At,
+ ct);
+
+ return Unit.Value;
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs
new file mode 100644
index 0000000..8aa0702
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs
@@ -0,0 +1,29 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator
+{
+ public sealed class RevokeRootCommand : ISessionCommand
+ {
+ public TUserId UserId { get; }
+
+ public RevokeRootCommand(TUserId userId)
+ {
+ UserId = userId;
+ }
+
+ public async Task ExecuteAsync(
+ AuthContext context,
+ ISessionIssuer issuer,
+ CancellationToken ct)
+ {
+ await issuer.RevokeRootAsync(
+ context.TenantId,
+ UserId,
+ context.At,
+ ct);
+
+ return Unit.Value;
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs
new file mode 100644
index 0000000..3d88afe
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs
@@ -0,0 +1,15 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure
+{
+ internal sealed record RevokeSessionCommand(string? TenantId, AuthSessionId SessionId) : ISessionCommand
+ {
+ public async Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct)
+ {
+ await issuer.RevokeSessionAsync(TenantId, SessionId, _.At, ct);
+ return Unit.Value;
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs
new file mode 100644
index 0000000..d57b479
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs
@@ -0,0 +1,13 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure
+{
+ internal sealed record RotateSessionCommand(SessionRotationContext RotationContext) : ISessionCommand>
+ {
+ public Task> ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct)
+ {
+ return issuer.RotateSessionAsync(RotationContext, ct);
+ }
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs
new file mode 100644
index 0000000..784df9b
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs
@@ -0,0 +1,44 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Errors;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure
+{
+ public sealed class UAuthSessionOrchestrator : ISessionOrchestrator
+ {
+ private readonly IAuthAuthority _authority;
+ private readonly ISessionIssuer _issuer;
+ private bool _executed;
+
+ public UAuthSessionOrchestrator(IAuthAuthority authority, ISessionIssuer issuer)
+ {
+ _authority = authority;
+ _issuer = issuer;
+ }
+
+ public async Task ExecuteAsync(AuthContext authContext, ISessionCommand command, CancellationToken ct = default)
+ {
+ if (_executed)
+ throw new InvalidOperationException("Session orchestrator can only be executed once per operation.");
+
+ _executed = true;
+
+ var decision = _authority.Decide(authContext);
+
+ switch (decision.Decision)
+ {
+ case AuthorizationDecision.Deny:
+ throw new UAuthAuthorizationException(decision.Reason);
+
+ case AuthorizationDecision.Challenge:
+ throw new UAuthChallengeRequiredException(decision.Reason);
+
+ case AuthorizationDecision.Allow:
+ break;
+ }
+
+ return await command.ExecuteAsync(authContext, _issuer, ct);
+ }
+
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs
new file mode 100644
index 0000000..d590374
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs
@@ -0,0 +1,101 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.Server.Infrastructure
+{
+ public sealed class UAuthSessionQueryService
+ : ISessionQueryService
+ {
+ private readonly ISessionStoreFactory _storeFactory;
+
+ public UAuthSessionQueryService(ISessionStoreFactory storeFactory)
+ {
+ _storeFactory = storeFactory;
+ }
+
+ public async Task> ValidateSessionAsync(
+ SessionValidationContext context,
+ CancellationToken ct = default)
+ {
+ var kernel = _storeFactory.Create(context.TenantId);
+
+ var session = await kernel.GetSessionAsync(
+ context.TenantId,
+ context.SessionId);
+
+ if (session is null)
+ return SessionValidationResult.Invalid(SessionState.NotFound);
+
+ var state = session.GetState(context.Now);
+ if (state != SessionState.Active)
+ return SessionValidationResult.Invalid(state);
+
+ var chain = await kernel.GetChainAsync(
+ context.TenantId,
+ session.ChainId);
+
+ if (chain is null || chain.IsRevoked)
+ return SessionValidationResult.Invalid(SessionState.Revoked);
+
+ var root = await kernel.GetSessionRootAsync(
+ context.TenantId,
+ session.UserId);
+
+ if (root is null || root.IsRevoked)
+ return SessionValidationResult.Invalid(SessionState.Revoked);
+
+ if (session.SecurityVersionAtCreation != root.SecurityVersion)
+ return SessionValidationResult.Invalid(SessionState.SecurityMismatch);
+
+ if (!session.Device.Matches(context.Device))
+ return SessionValidationResult.Invalid(SessionState.DeviceMismatch);
+
+ if (session.ShouldUpdateLastSeen(context.Now))
+ {
+ var updated = session.Touch(context.Now);
+ await kernel.SaveSessionAsync(context.TenantId, updated);
+ session = updated;
+ }
+
+ return SessionValidationResult.Active(session, chain, root);
+ }
+
+ public Task?> GetSessionAsync(
+ string? tenantId,
+ AuthSessionId sessionId,
+ CancellationToken ct = default)
+ {
+ var kernel = _storeFactory.Create(tenantId);
+ return kernel.GetSessionAsync(tenantId, sessionId);
+ }
+
+ public Task>> GetSessionsByChainAsync(
+ string? tenantId,
+ ChainId chainId,
+ CancellationToken ct = default)
+ {
+ var kernel = _storeFactory.Create(tenantId);
+ return kernel.GetSessionsByChainAsync(tenantId, chainId);
+ }
+
+ public Task>> GetChainsByUserAsync(
+ string? tenantId,
+ TUserId userId,
+ CancellationToken ct = default)
+ {
+ var kernel = _storeFactory.Create(tenantId);
+ return kernel.GetChainsByUserAsync(tenantId, userId);
+ }
+
+ public Task ResolveChainIdAsync(
+ string? tenantId,
+ AuthSessionId sessionId,
+ CancellationToken ct = default)
+ {
+ var kernel = _storeFactory.Create(tenantId);
+ return kernel.GetChainIdBySessionAsync(tenantId, sessionId);
+ }
+ }
+
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs
index 5a3b064..c6e526a 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs
@@ -4,6 +4,6 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure
{
public sealed class SystemClock : IClock
{
- public DateTime UtcNow => DateTime.UtcNow;
+ public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs
index 67e8ea6..4aff464 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs
@@ -8,6 +8,6 @@ public sealed record TokenIssuanceContext
public string? TenantId { get; init; }
public IReadOnlyCollection Claims { get; init; } = Array.Empty();
public string? SessionId { get; init; }
- public DateTime IssuedAt { get; init; }
+ public DateTimeOffset IssuedAt { get; init; }
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs
deleted file mode 100644
index 7bcd741..0000000
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs
+++ /dev/null
@@ -1,337 +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.Server.Issuers;
-using CodeBeam.UltimateAuth.Server.Options;
-
-namespace CodeBeam.UltimateAuth.Server.Infrastructure
-{
- ///
- /// Default UltimateAuth session store implementation.
- /// Handles session, chain, and root orchestration on top of a kernel store.
- ///
- public sealed class UAuthSessionOrchestrator : ISessionOrchestrator
- {
- private readonly ISessionStoreFactory _factory;
- private readonly UAuthSessionIssuer _sessionIssuer;
- private readonly UAuthServerOptions _serverOptions;
-
- public UAuthSessionOrchestrator(ISessionStoreFactory factory, UAuthSessionIssuer sessionIssuer, UAuthServerOptions serverOptions)
- {
- _factory = factory;
- _sessionIssuer = sessionIssuer;
- _serverOptions = serverOptions;
- }
-
- public async Task> CreateLoginSessionAsync(AuthenticatedSessionContext context)
- {
- var kernel = _factory.Create(context.TenantId);
-
- var root = await kernel.GetSessionRootAsync(
- context.TenantId,
- context.UserId);
-
- if (root is null)
- {
- root = UAuthSessionRoot.Create(
- context.TenantId,
- context.UserId,
- context.Now);
- }
- else if (root.IsRevoked)
- {
- throw new UAuthSessionRootRevokedException(context.UserId!);
- }
-
- ISessionChain chain;
-
- if (context.ChainId is not null)
- {
- chain = await kernel.GetChainAsync(
- context.TenantId,
- context.ChainId.Value)
- ?? throw new UAuthSessionChainNotFoundException(
- context.ChainId.Value);
-
- if (chain.IsRevoked)
- throw new UAuthSessionChainRevokedException(
- chain.ChainId);
- }
- else
- {
- chain = UAuthSessionChain.Create(
- ChainId.New(),
- context.TenantId,
- context.UserId,
- root.SecurityVersion,
- context.Claims);
- }
-
- var issuedSession = await _sessionIssuer.IssueAsync(
- context,
- chain);
-
- await kernel.ExecuteAsync(async () =>
- {
- await kernel.SaveSessionAsync(
- context.TenantId,
- issuedSession.Session);
-
- var updatedChain = chain.AttachSession(
- issuedSession.Session.SessionId);
-
- await kernel.SaveChainAsync(
- context.TenantId,
- updatedChain);
-
- await kernel.SaveSessionRootAsync(
- context.TenantId,
- root);
- });
-
- return issuedSession;
- }
-
- public async Task> RotateSessionAsync(SessionRotationContext context)
- {
- var kernel = _factory.Create(context.TenantId);
-
- var currentSession = await kernel.GetSessionAsync(
- context.TenantId,
- context.CurrentSessionId);
-
- if (currentSession is null)
- throw new UAuthSessionNotFoundException(context.CurrentSessionId);
-
- if (currentSession.IsRevoked)
- throw new UAuthSessionRevokedException(context.CurrentSessionId);
-
- var state = currentSession.GetState(context.Now);
- if (state != SessionState.Active)
- throw new UAuthSessionInvalidStateException(
- context.CurrentSessionId, state);
-
- var chainId = await kernel.GetChainIdBySessionAsync(
- context.TenantId,
- context.CurrentSessionId);
-
- if (chainId is null)
- throw new UAuthSessionChainLinkMissingException(context.CurrentSessionId);
-
- var chain = await kernel.GetChainAsync(
- context.TenantId,
- chainId.Value);
-
- if (chain is null || chain.IsRevoked)
- throw new UAuthSessionChainRevokedException(chainId.Value);
-
- var root = await kernel.GetSessionRootAsync(
- context.TenantId,
- currentSession.UserId);
-
- if (root is null || root.IsRevoked)
- throw new UAuthSessionRootRevokedException(
- currentSession.UserId!);
-
- if (currentSession.SecurityVersionAtCreation != root.SecurityVersion)
- throw new UAuthSessionSecurityMismatchException(
- context.CurrentSessionId,
- root.SecurityVersion);
-
- var issueContext = new AuthenticatedSessionContext
- {
- TenantId = root.TenantId,
- UserId = currentSession.UserId,
- Now = context.Now,
- DeviceInfo = context.Device,
- Claims = context.Claims
- };
-
- var issuedSession = await _sessionIssuer.IssueAsync(
- issueContext,
- chain);
-
- await kernel.ExecuteAsync(async () =>
- {
- await kernel.RevokeSessionAsync(
- context.TenantId,
- context.CurrentSessionId,
- context.Now);
-
- await kernel.SaveSessionAsync(
- context.TenantId,
- issuedSession.Session);
-
- var rotatedChain = chain.RotateSession(
- issuedSession.Session.SessionId);
-
- await kernel.SaveChainAsync(
- context.TenantId,
- rotatedChain);
-
- await kernel.SaveSessionRootAsync(
- context.TenantId,
- root);
- });
-
- return issuedSession;
- }
-
- public async Task> ValidateSessionAsync(
- SessionValidationContext context)
- {
- var kernel = _factory.Create(context.TenantId);
-
- // 1️⃣ Load session
- var session = await kernel.GetSessionAsync(
- context.TenantId,
- context.SessionId);
-
- if (session is null)
- return SessionValidationResult.Invalid(SessionState.NotFound);
-
- var state = session.GetState(context.Now);
-
- if (state != SessionState.Active)
- return SessionValidationResult.Invalid(state);
-
- // 2️⃣ Resolve chain
- var chainId = await kernel.GetChainIdBySessionAsync(
- context.TenantId,
- context.SessionId);
-
- if (chainId is null)
- return SessionValidationResult.Invalid(SessionState.Invalid);
-
- var chain = await kernel.GetChainAsync(
- context.TenantId,
- chainId.Value);
-
- if (chain is null || chain.IsRevoked)
- return SessionValidationResult.Invalid(SessionState.Revoked);
-
- // 3️⃣ Resolve root
- var root = await kernel.GetSessionRootAsync(
- context.TenantId,
- session.UserId);
-
- if (root is null || root.IsRevoked)
- return SessionValidationResult.Invalid(SessionState.Revoked);
-
- // 4️⃣ Security version check
- if (session.SecurityVersionAtCreation != root.SecurityVersion)
- return SessionValidationResult.Invalid(SessionState.SecurityMismatch);
-
- // 5️⃣ Device check
- if (!session.Device.Matches(context.Device))
- return SessionValidationResult.Invalid(SessionState.DeviceMismatch);
-
- // 6️⃣ Touch session (best-effort)
- if (session.ShouldUpdateLastSeen(context.Now))
- {
- var updated = session.Touch(context.Now);
- await kernel.SaveSessionAsync(context.TenantId, updated);
- session = updated;
- }
-
- // 7️⃣ Success
- return SessionValidationResult.Active(
- session,
- chain,
- root);
- }
-
- public Task?> GetSessionAsync(string? tenantId, AuthSessionId sessionId)
- {
- var kernel = _factory.Create(tenantId);
- return kernel.GetSessionAsync(tenantId, sessionId);
- }
-
- public Task>> GetSessionsAsync(string? tenantId, ChainId chainId)
- {
- var kernel = _factory.Create(tenantId);
- return kernel.GetSessionsByChainAsync(tenantId, chainId);
- }
-
- public Task>> GetChainsAsync(string? tenantId, TUserId userId)
- {
- var kernel = _factory.Create(tenantId);
- return kernel.GetChainsByUserAsync(tenantId, userId);
- }
-
- public async Task ResolveChainIdAsync(
- string? tenantId,
- AuthSessionId sessionId)
- {
- var kernel = _factory.Create(tenantId);
- return await kernel.GetChainIdBySessionAsync(tenantId, sessionId);
- }
-
- public async Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at)
- {
- var kernel = _factory.Create(tenantId);
- await kernel.RevokeSessionAsync(tenantId, sessionId, at);
- }
-
- public async Task RevokeAllSessionsAsync(
- string? tenantId,
- TUserId userId,
- DateTime at)
- {
- var kernel = _factory.Create(tenantId);
- await kernel.RevokeSessionRootAsync(tenantId, userId, at);
- }
-
- public async Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at)
- {
- var kernel = _factory.Create(tenantId);
- await kernel.RevokeChainAsync(tenantId, chainId, at);
- }
-
- public async Task RevokeAllChainsAsync(
- string? tenantId,
- TUserId userId,
- ChainId? exceptChainId,
- DateTime at)
- {
- var kernel = _factory.Create(tenantId);
-
- var chains = await kernel.GetChainsByUserAsync(tenantId, userId);
-
- await kernel.ExecuteAsync(async () =>
- {
- foreach (var chain in chains)
- {
- if (exceptChainId.HasValue &&
- chain.ChainId.Equals(exceptChainId.Value))
- {
- continue;
- }
-
- if (!chain.IsRevoked)
- {
- await kernel.RevokeChainAsync(
- tenantId,
- chain.ChainId,
- at);
- }
- }
- });
- }
-
- public async Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at)
- {
- var kernel = _factory.Create(tenantId);
-
- await kernel.ExecuteAsync(async () =>
- {
- await kernel.RevokeSessionRootAsync(
- tenantId,
- userId,
- at);
- });
- }
-
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs
index 201d6b5..b88bb9c 100644
--- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs
@@ -5,65 +5,278 @@
using CodeBeam.UltimateAuth.Core.Domain.Session;
using CodeBeam.UltimateAuth.Server.Options;
using Microsoft.Extensions.Options;
+using System.Security;
namespace CodeBeam.UltimateAuth.Server.Issuers
{
- ///
- /// UltimateAuth session issuer responsible for creating
- /// opaque authentication sessions.
- ///
public sealed class UAuthSessionIssuer : ISessionIssuer
{
private readonly IOpaqueTokenGenerator _opaqueGenerator;
+ private readonly ISessionStoreFactory _storeFactory;
private readonly UAuthServerOptions _options;
- public UAuthSessionIssuer(IOpaqueTokenGenerator opaqueGenerator, IOptions options)
+ public UAuthSessionIssuer(IOpaqueTokenGenerator opaqueGenerator, ISessionStoreFactory storeFactory, IOptions options)
{
_opaqueGenerator = opaqueGenerator;
+ _storeFactory = storeFactory;
_options = options.Value;
}
- // chain is intentionally provided for future policy extensions
- public Task> IssueAsync(
- AuthenticatedSessionContext context,
- ISessionChain chain,
- CancellationToken cancellationToken = default)
+ public async Task> IssueLoginSessionAsync(AuthenticatedSessionContext context, CancellationToken cancellationToken = default)
{
+ // Defensive guard — enforcement belongs to Authority
if (_options.Mode == UAuthMode.PureJwt)
{
- throw new InvalidOperationException(
- "Session issuer cannot be used in PureJwt mode.");
+ throw new InvalidOperationException("Session issuance is not allowed in PureJwt mode.");
}
+ var now = context.Now;
var opaqueSessionId = _opaqueGenerator.Generate();
- var expiresAt = context.Now.Add(_options.Session.Lifetime);
+
+ var expiresAt = now.Add(_options.Session.Lifetime);
if (_options.Session.MaxLifetime is not null)
{
- var absoluteExpiry =
- context.Now.Add(_options.Session.MaxLifetime.Value);
-
+ var absoluteExpiry = now.Add(_options.Session.MaxLifetime.Value);
if (absoluteExpiry < expiresAt)
expiresAt = absoluteExpiry;
}
- var session = UAuthSession.Create(
- sessionId: new AuthSessionId(opaqueSessionId),
- tenantId: context.TenantId,
- userId: context.UserId,
- now: context.Now,
- expiresAt: expiresAt,
- claims: context.Claims,
- device: context.DeviceInfo,
- metadata: context.Metadata
- );
-
- return Task.FromResult(new IssuedSession
+ var store = _storeFactory.Create(context.TenantId);
+
+ IssuedSession? issued = null;
+
+ await store.ExecuteAsync(async () =>
+ {
+ // Root
+ var root =
+ await store.GetSessionRootAsync(context.TenantId, context.UserId)
+ ?? UAuthSessionRoot.Create(
+ context.TenantId,
+ context.UserId,
+ now);
+
+ // Chain
+ var claimsSnapshot = context.Claims;
+
+ var chain = UAuthSessionChain.Create(
+ ChainId.New(),
+ context.TenantId,
+ context.UserId,
+ root.SecurityVersion,
+ claimsSnapshot);
+
+ root = root.AttachChain(chain, now);
+
+ // Session
+ var session = UAuthSession.Create(
+ sessionId: new AuthSessionId(opaqueSessionId),
+ tenantId: context.TenantId,
+ userId: context.UserId,
+ chainId: chain.ChainId,
+ now: now,
+ expiresAt: expiresAt,
+ claims: context.Claims,
+ device: context.DeviceInfo,
+ metadata: context.Metadata
+ );
+
+ // Persist (order is intentional)
+ await store.SaveSessionRootAsync(context.TenantId, root);
+ await store.SaveChainAsync(context.TenantId, chain);
+ await store.SaveSessionAsync(context.TenantId, session);
+ await store.SetActiveSessionIdAsync(
+ context.TenantId,
+ chain.ChainId,
+ session.SessionId);
+
+ issued = new IssuedSession
+ {
+ Session = session,
+ OpaqueSessionId = opaqueSessionId,
+ IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid
+ };
+ });
+
+ return issued!;
+ }
+
+ public async Task> RotateSessionAsync(SessionRotationContext context, CancellationToken ct = default)
+ {
+ var now = context.Now;
+ var store = _storeFactory.Create(context.TenantId);
+
+ IssuedSession? issued = null;
+
+ await store.ExecuteAsync(async () =>
{
- Session = session,
- OpaqueSessionId = opaqueSessionId,
- IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid
+ var session = await store.GetSessionAsync(
+ context.TenantId,
+ context.CurrentSessionId);
+
+ if (session is null)
+ throw new SecurityException("Session not found.");
+
+ if (session.IsRevoked || session.ExpiresAt <= now)
+ throw new SecurityException("Session is no longer valid.");
+
+ var chainId = session.ChainId;
+
+ var chain = await store.GetChainAsync(
+ context.TenantId,
+ chainId);
+
+ if (chain is null || chain.IsRevoked)
+ throw new SecurityException("Session chain is invalid.");
+
+ var opaqueSessionId = _opaqueGenerator.Generate();
+
+ var expiresAt = now.Add(_options.Session.Lifetime);
+
+ if (_options.Session.MaxLifetime is not null)
+ {
+ var absoluteExpiry = now.Add(_options.Session.MaxLifetime.Value);
+ if (absoluteExpiry < expiresAt)
+ expiresAt = absoluteExpiry;
+ }
+
+ var newSession = UAuthSession.Create(
+ sessionId: new AuthSessionId(opaqueSessionId),
+ tenantId: session.TenantId,
+ userId: session.UserId,
+ chainId: chain.ChainId,
+ now: now,
+ expiresAt: expiresAt,
+ claims: chain.ClaimsSnapshot,
+ device: session.Device,
+ metadata: session.Metadata
+ );
+
+ await store.SaveSessionAsync(context.TenantId, newSession);
+
+ var rotatedChain = chain.RotateSession(newSession.SessionId);
+
+ await store.SaveChainAsync(context.TenantId, rotatedChain);
+ await store.SetActiveSessionIdAsync(
+ context.TenantId,
+ chain.ChainId,
+ newSession.SessionId);
+
+ await store.RevokeSessionAsync(
+ context.TenantId,
+ session.SessionId,
+ now);
+
+ issued = new IssuedSession
+ {
+ Session = newSession,
+ OpaqueSessionId = opaqueSessionId,
+ IsMetadataOnly = _options.Mode == UAuthMode.SemiHybrid
+ };
});
+
+ return issued!;
}
+
+ public async Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default)
+ {
+ var store = _storeFactory.Create(tenantId);
+
+ await store.ExecuteAsync(async () =>
+ {
+ var session = await store.GetSessionAsync(tenantId, sessionId);
+ if (session is null)
+ return;
+
+ if (session.IsRevoked)
+ return;
+
+ await store.RevokeSessionAsync(
+ tenantId,
+ sessionId,
+ at.UtcDateTime);
+ });
+ }
+
+ public async Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at, CancellationToken ct = default)
+ {
+ var store = _storeFactory.Create(tenantId);
+
+ await store.ExecuteAsync(async () =>
+ {
+ var chain = await store.GetChainAsync(tenantId, chainId);
+ if (chain is null)
+ return;
+
+ if (chain.IsRevoked)
+ return;
+
+ await store.RevokeChainAsync(tenantId, chainId, at.UtcDateTime);
+
+ if (chain.ActiveSessionId is not null)
+ {
+ await store.RevokeSessionAsync(tenantId, chain.ActiveSessionId.Value, at.UtcDateTime);
+ }
+ });
+ }
+
+ public async Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default)
+ {
+ var store = _storeFactory.Create(tenantId);
+
+ await store.ExecuteAsync(async () =>
+ {
+ var root = await store.GetSessionRootAsync(tenantId, userId);
+ if (root is null)
+ return;
+
+ foreach (var chain in root.Chains)
+ {
+ if (exceptChainId.HasValue && chain.ChainId.Equals(exceptChainId.Value))
+ {
+ continue;
+ }
+
+ await store.RevokeChainAsync(tenantId, chain.ChainId, at.UtcDateTime);
+
+ if (chain.ActiveSessionId is not null)
+ {
+ await store.RevokeSessionAsync(tenantId, chain.ActiveSessionId.Value, at.UtcDateTime);
+ }
+ }
+
+ await store.SaveSessionRootAsync(tenantId, root);
+ });
+ }
+
+ public async Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at, CancellationToken ct = default)
+ {
+ var store = _storeFactory.Create(tenantId);
+
+ await store.ExecuteAsync(async () =>
+ {
+ var root = await store.GetSessionRootAsync(tenantId, userId);
+ if (root is null)
+ return;
+
+ var revokedRoot = root.Revoke(at);
+
+ await store.SaveSessionRootAsync(tenantId, revokedRoot);
+
+ foreach (var chain in root.Chains)
+ {
+ await store.RevokeChainAsync(tenantId, chain.ChainId, at);
+
+ if (chain.ActiveSessionId is not null)
+ {
+ await store.RevokeSessionAsync(
+ tenantId,
+ chain.ActiveSessionId.Value,
+ at);
+ }
+ }
+ });
+ }
+
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs
index 084e3b0..f2d4beb 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs
@@ -1,23 +1,31 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Server.Infrastructure;
+using Microsoft.AspNetCore.Http;
namespace CodeBeam.UltimateAuth.Server.Services
{
internal sealed class UAuthFlowService : IUAuthFlowService
{
private readonly IUAuthUserService _users;
- private readonly IUAuthSessionService _sessions;
- private readonly IUAuthTokenService _tokens;
+ private readonly ISessionOrchestrator _orchestrator;
+ private readonly ISessionQueryService _queries;
+ private readonly ITokenIssuer _tokens;
+ private readonly IRefreshTokenResolver _refreshTokens;
public UAuthFlowService(
IUAuthUserService users,
- IUAuthSessionService sessions,
- IUAuthTokenService tokens)
+ ISessionOrchestrator orchestrator,
+ ISessionQueryService queries,
+ ITokenIssuer tokens,
+ IRefreshTokenResolver refreshTokens)
{
_users = users;
- _sessions = sessions;
+ _orchestrator = orchestrator;
+ _queries = queries;
_tokens = tokens;
+ _refreshTokens = refreshTokens;
}
public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default)
@@ -47,87 +55,122 @@ public Task ExternalLoginAsync(ExternalLoginRequest request, Cancel
public async Task LoginAsync(LoginRequest request, CancellationToken ct = default)
{
- var now = request.At ?? DateTime.UtcNow;
+ var now = request.At ?? DateTimeOffset.UtcNow;
var device = request.DeviceInfo ?? DeviceInfo.Unknown;
- var authResult = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct);
+ // 1️⃣ Authenticate user (NO session yet)
+ var auth = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct);
- if (!authResult.Succeeded)
- {
+ if (!auth.Succeeded)
return LoginResult.Failed();
- }
- var sessionResult = await _sessions.IssueSessionAfterAuthenticationAsync(request.TenantId,
- new AuthenticatedSessionContext
- {
- TenantId = request.TenantId,
- UserId = authResult.UserId!,
- Now = now,
- DeviceInfo = device,
- Claims = authResult.Claims,
- ChainId = request.ChainId
- });
+ // 2️⃣ Create authenticated context
+ var sessionContext = new AuthenticatedSessionContext
+ {
+ TenantId = request.TenantId,
+ UserId = auth.UserId!,
+ Now = now,
+ DeviceInfo = device,
+ Claims = auth.Claims,
+ ChainId = request.ChainId
+ };
+
+ var authContext = AuthContext.ForAuthenticatedUser(
+ request.TenantId,
+ AuthOperation.Login,
+ now,
+ DeviceContext.From(device));
+
+ // 3️⃣ Issue session THROUGH orchestrator
+ var issuedSession = await _orchestrator.ExecuteAsync(
+ authContext,
+ new CreateLoginSessionCommand(sessionContext),
+ ct);
+ // 4️⃣ Optional tokens
AuthTokens? tokens = null;
if (request.RequestTokens)
{
- tokens = await _tokens.CreateTokensAsync(
- new TokenIssueContext
+ var access = await _tokens.IssueAccessTokenAsync(
+ new TokenIssuanceContext
{
TenantId = request.TenantId,
- Session = sessionResult.Session,
- Now = now
+ UserId = auth.UserId!.ToString()!,
+ SessionId = issuedSession.Session.SessionId
},
ct);
+
+ var refresh = await _tokens.IssueRefreshTokenAsync(
+ new TokenIssuanceContext
+ {
+ TenantId = request.TenantId,
+ UserId = auth.UserId!.ToString()!,
+ SessionId = issuedSession.Session.SessionId
+ },
+ ct);
+
+ tokens = new AuthTokens { AccessToken = access, RefreshToken = refresh };
}
- return LoginResult.Success(sessionResult.Session.SessionId, tokens);
+ return LoginResult.Success(issuedSession.Session.SessionId, tokens);
}
- public async Task LogoutAsync(LogoutRequest request, CancellationToken ct = default)
+ public Task LogoutAsync(LogoutRequest request, CancellationToken ct = default)
{
- var at = request.At ?? DateTime.UtcNow;
- await _sessions.RevokeSessionAsync(request.TenantId, request.SessionId, at);
+ var now = request.At ?? DateTimeOffset.UtcNow;
+ var authContext = AuthContext.System(request.TenantId, AuthOperation.Revoke,now);
+
+ return _orchestrator.ExecuteAsync(authContext, new RevokeSessionCommand