From b95b1a4a9f650a5697c1eb7050e853d2b078d51f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Tue, 16 Dec 2025 23:55:39 +0300 Subject: [PATCH 1/4] Start to Authority Layer --- .../Abstractions/Authority/IAuthAuthority.cs | 10 +++++ .../Authority/IAuthorityInvariant.cs | 9 ++++ .../Authority/IAuthorityPolicy.cs | 10 +++++ .../Contracts/Authority/AuthContext.cs | 17 ++++++++ .../Contracts/Authority/AuthOperation.cs | 11 +++++ .../Authority/AuthorizationDecision.cs | 10 +++++ .../Authority/AuthorizationResult.cs | 29 +++++++++++++ .../Contracts/Authority/DeviceContext.cs | 16 +++++++ .../Authority/SessionAccessContext.cs | 18 ++++++++ .../Authority/DefaultAuthAuthority.cs | 43 +++++++++++++++++++ .../Authority/DeviceTrustPolicy.cs | 32 ++++++++++++++ .../Authority/ExpiredSessionInvariant.cs | 27 ++++++++++++ .../Authority/UAuthModeOperationPolicy.cs | 39 +++++++++++++++++ .../InvalidOrRevokedSessionInvariant.cs | 31 +++++++++++++ 14 files changed, 302 insertions(+) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authority/SessionAccessContext.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DefaultAuthAuthority.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceTrustPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/UAuthModeOperationPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/InvalidOrRevokedSessionInvariant.cs 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/Contracts/Authority/AuthContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs new file mode 100644 index 0000000..5508832 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs @@ -0,0 +1,17 @@ +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 Now { get; init; } + } +} 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..e1801bf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum AuthOperation + { + Login, + Access, + Refresh, + Revoke, + Logout + } +} 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..b750d27 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs @@ -0,0 +1,16 @@ +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; } + } + +} 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/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/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/InvalidOrRevokedSessionInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/InvalidOrRevokedSessionInvariant.cs new file mode 100644 index 0000000..1929bb5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/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(); + } + } +} From 79784111ca0519addd2270cbed1fa53cab0deece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Thu, 18 Dec 2025 00:04:14 +0300 Subject: [PATCH 2/4] Complete Basic Authority Layer --- .../Users/Models/UserDto.cs | 4 +- .../Users/Models/UserProfileDto.cs | 2 +- .../Abstractions/Infrastructure/IClock.cs | 2 +- .../Abstractions/Issuers/ISessionIssuer.cs | 15 +- .../Services/IUAuthSessionService.cs | 10 +- .../Abstractions/Stores/ISessionStore.cs | 6 +- .../Stores/ISessionStoreKernel.cs | 20 +- .../Contracts/Authority/AuthContext.cs | 46 ++- .../Contracts/Authority/AuthOperation.cs | 3 +- .../Contracts/Authority/DeviceContext.cs | 14 +- .../Contracts/Login/LoginRequest.cs | 2 +- .../Contracts/Logout/LogoutAllRequest.cs | 2 +- .../Contracts/Logout/LogoutRequest.cs | 6 +- .../Session/AuthenticatedSessionContext.cs | 2 +- .../Session/SessionRotationContext.cs | 2 +- .../Session/SessionValidationContext.cs | 2 +- .../Contracts/Token/TokenIssueContext.cs | 2 +- .../Contracts/Token/TokenValidationResult.cs | 6 +- .../Contracts/Unit.cs | 7 + .../Domain/Session/ChainId.cs | 4 + .../Domain/Session/ISession.cs | 18 +- .../Domain/Session/ISessionChain.cs | 4 +- .../Domain/Session/ISessionRoot.cs | 8 +- .../Domain/Session/UAuthSession.cs | 44 ++- .../Domain/Session/UAuthSessionChain.cs | 6 +- .../Domain/Session/UAuthSessionRoot.cs | 30 +- .../Domain/Token/UAuthJwtTokenDescriptor.cs | 2 +- .../UAuthChallengeRequiredException.cs | 10 + .../Base/UAuthAuthorizationException.cs | 10 + .../Events/SessionCreatedContext.cs | 4 +- .../Events/SessionRefreshedContext.cs | 4 +- .../Events/SessionRevokedContext.cs | 4 +- .../Events/UserLoggedInContext.cs | 4 +- .../Events/UserLoggedOutContext.cs | 4 +- .../InvalidOrRevokedSessionInvariant.cs | 0 .../Infrastructure/UserRecord.cs | 2 +- .../Infrastructure/ISessionOrchestrator.cs | 30 -- .../Orchestrator/CreateLoginSessionCommand.cs | 13 + .../Orchestrator/ISessionCommand.cs | 10 + .../Orchestrator/ISessionOrchestrator.cs | 8 + .../Orchestrator/ISessionQueryService.cs | 18 + .../Orchestrator/RevokeAllChainsCommand.cs | 24 ++ .../Orchestrator/RevokeChainCommand.cs | 30 ++ .../Orchestrator/RevokeRootCommand.cs | 29 ++ .../Orchestrator/RevokeSessionCommand.cs | 15 + .../Orchestrator/RotateSessionCommand.cs | 13 + .../Orchestrator/UAuthSessionOrchestrator.cs | 44 +++ .../Orchestrator/UAuthSessionQueryService.cs | 101 ++++++ .../Infrastructure/SystemClock.cs | 2 +- .../Infrastructure/TokenIssuanceContext.cs | 2 +- .../UAuthSessionOrchestrator.cs | 337 ------------------ .../Issuers/UAuthSessionIssuer.cs | 275 ++++++++++++-- .../Services/UAuthFlowService.cs | 10 +- .../Services/UAuthSessionService.cs | 117 +++--- .../Services/UAuthUserService.cs | 2 +- 55 files changed, 826 insertions(+), 565 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Unit.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthAuthorizationException.cs rename src/CodeBeam.UltimateAuth.Core/Infrastructure/{ => Authority}/InvalidOrRevokedSessionInvariant.cs (100%) delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionQueryService.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionQueryService.cs delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs index 8aa7cb5..5a8de73 100644 --- a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs @@ -10,7 +10,7 @@ public sealed class UserDto public bool IsActive { get; init; } public bool IsEmailConfirmed { get; init; } - public DateTime CreatedAt { get; init; } - public DateTime? LastLoginAt { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? LastLoginAt { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs index 9b2496e..141d34a 100644 --- a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs @@ -9,6 +9,6 @@ public sealed class UserProfileDto public bool IsEmailConfirmed { get; init; } - public DateTime CreatedAt { get; init; } + public DateTimeOffset CreatedAt { get; init; } } } 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/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/Contracts/Authority/AuthContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs index 5508832..bce952e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs @@ -12,6 +12,50 @@ public sealed record AuthContext public DeviceContext Device { get; init; } - public DateTimeOffset Now { 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 index e1801bf..8f41f0d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs @@ -6,6 +6,7 @@ public enum AuthOperation Access, Refresh, Revoke, - Logout + Logout, + System } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs index b750d27..8cbdeff 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceContext.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts { public sealed record DeviceContext { @@ -11,6 +13,16 @@ public sealed record DeviceContext 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/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/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/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/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/InvalidOrRevokedSessionInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Infrastructure/InvalidOrRevokedSessionInvariant.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs 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/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..171621c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -47,7 +47,7 @@ public Task ExternalLoginAsync(ExternalLoginRequest request, Cancel public async Task LoginAsync(LoginRequest request, CancellationToken ct = default) { - var now = request.At ?? DateTime.UtcNow; + var at = request.At ?? DateTimeOffset.UtcNow; var device = request.DeviceInfo ?? DeviceInfo.Unknown; var authResult = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct); @@ -62,7 +62,7 @@ public async Task LoginAsync(LoginRequest request, CancellationToke { TenantId = request.TenantId, UserId = authResult.UserId!, - Now = now, + Now = at, DeviceInfo = device, Claims = authResult.Claims, ChainId = request.ChainId @@ -77,7 +77,7 @@ public async Task LoginAsync(LoginRequest request, CancellationToke { TenantId = request.TenantId, Session = sessionResult.Session, - Now = now + At = at }, ct); } @@ -87,13 +87,13 @@ public async Task LoginAsync(LoginRequest request, CancellationToke public async Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) { - var at = request.At ?? DateTime.UtcNow; + var at = request.At ?? DateTimeOffset.UtcNow; await _sessions.RevokeSessionAsync(request.TenantId, request.SessionId, at); } public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default) { - var at = request.At ?? DateTime.UtcNow; + var at = request.At ?? DateTimeOffset.UtcNow; if (request.CurrentSessionId is null) throw new InvalidOperationException( diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs index 43cdf35..4d0e0df 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs @@ -2,22 +2,25 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Infrastructure.Orchestrator; namespace CodeBeam.UltimateAuth.Server.Services { internal sealed class UAuthSessionService : IUAuthSessionService { private readonly ISessionOrchestrator _orchestrator; + private readonly ISessionQueryService _sessionQueryService; - public UAuthSessionService(ISessionOrchestrator orchestrator) + public UAuthSessionService(ISessionOrchestrator orchestrator, ISessionQueryService sessionQueryService) { _orchestrator = orchestrator; + _sessionQueryService = sessionQueryService; } public Task> ValidateSessionAsync( string? tenantId, AuthSessionId sessionId, - DateTime now) + DateTimeOffset now) { var context = new SessionValidationContext() { @@ -25,100 +28,84 @@ public Task> ValidateSessionAsync( Now = now }; - return _orchestrator.ValidateSessionAsync(context); + return _sessionQueryService.ValidateSessionAsync(context); } public Task>> GetChainsAsync( string? tenantId, TUserId userId) - => _orchestrator.GetChainsAsync( + => _sessionQueryService.GetChainsByUserAsync( tenantId, userId); public Task>> GetSessionsAsync( string? tenantId, ChainId chainId) - => _orchestrator.GetSessionsAsync( + => _sessionQueryService.GetSessionsByChainAsync( tenantId, chainId); public Task?> GetSessionAsync( string? tenantId, AuthSessionId sessionId) - => _orchestrator.GetSessionAsync( + => _sessionQueryService.GetSessionAsync( tenantId, sessionId); - public Task RevokeSessionAsync( - string? tenantId, - AuthSessionId sessionId, - DateTime at) - => _orchestrator.RevokeSessionAsync( - tenantId, - sessionId, - at); + public Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTimeOffset at) + { + var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); + var command = new RevokeSessionCommand(tenantId,sessionId); - public Task ResolveChainIdAsync( - string? tenantId, - AuthSessionId sessionId) - => _orchestrator.ResolveChainIdAsync(tenantId, sessionId); + return _orchestrator.ExecuteAsync(authContext, command); + } - public Task RevokeAllChainsAsync( - string? tenantId, - TUserId userId, - ChainId? exceptChainId, - DateTime at) - => _orchestrator.RevokeAllChainsAsync( - tenantId, - userId, - exceptChainId, - at); + public Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId) + => _sessionQueryService.ResolveChainIdAsync(tenantId, sessionId); - public Task RevokeChainAsync( - string? tenantId, - ChainId chainId, - DateTime at) - => _orchestrator.RevokeChainAsync( - tenantId, - chainId, - at); + public Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTimeOffset at) + { + var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); + var command = new RevokeAllChainsCommand(userId, exceptChainId); - public Task RevokeRootAsync( - string? tenantId, - TUserId userId, - DateTime at) - => _orchestrator.RevokeRootAsync( - tenantId, - userId, - at); + return _orchestrator.ExecuteAsync(authContext, command); + } - public Task?> GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId) + public Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTimeOffset at) { - // TODO: Implement this method - throw new NotImplementedException(); + var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); + var command = new RevokeChainCommand(chainId); + + return _orchestrator.ExecuteAsync(authContext, command); } - public Task> IssueSessionAfterAuthenticationAsync( - string? tenantId, - AuthenticatedSessionContext context, - CancellationToken cancellationToken = default) + public Task RevokeRootAsync(string? tenantId, TUserId userId, DateTimeOffset at) { - if (context.UserId is null) - throw new InvalidOperationException( - "Authenticated session context requires a valid user id."); + var authContext = AuthContext.System(tenantId, AuthOperation.Revoke, at); + var command = new RevokeRootCommand(userId); - // Authenticated → IssueContext map - var issueContext = new AuthenticatedSessionContext - { - TenantId = tenantId, - UserId = context.UserId, - Now = context.Now, - DeviceInfo = context.DeviceInfo, - Claims = context.Claims, - ChainId = context.ChainId - }; + return _orchestrator.ExecuteAsync(authContext, command); + } + + public async Task?> GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId) + { + var chainId = await _sessionQueryService.ResolveChainIdAsync(tenantId, sessionId); + + if (chainId is null) + return null; + + var sessions = await _sessionQueryService.GetSessionsByChainAsync(tenantId, chainId.Value); + + return sessions.FirstOrDefault(s => s.SessionId == sessionId); + } + + public Task> IssueSessionAfterAuthenticationAsync(string? tenantId, AuthenticatedSessionContext context, CancellationToken ct = default) + { + var deviceContext = DeviceContext.From(context.DeviceInfo); + var authContext = AuthContext.ForAuthenticatedUser(tenantId, AuthOperation.Login, context.Now, deviceContext); + var command = new CreateLoginSessionCommand(context); - return _orchestrator.CreateLoginSessionAsync(issueContext); + return _orchestrator.ExecuteAsync(authContext, command, ct); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs index bb02e0b..5a2fdba 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs @@ -46,7 +46,7 @@ await _userStore.CreateAsync( Id = userId, Username = request.Identifier, PasswordHash = hash, - CreatedAt = DateTime.UtcNow + CreatedAt = DateTimeOffset.UtcNow }, ct); From 36e7f9cda78ac3dc3e60c9b105e08e9bc9bf573b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Thu, 18 Dec 2025 16:56:49 +0300 Subject: [PATCH 3/4] Finalize Server Project's Layers --- .../Infrastructure/IRefreshTokenResolver.cs | 10 + .../Abstractions/Stores/ITokenStore.cs | 11 +- .../Abstractions/Stores/ITokenStoreFactory.cs | 7 + .../Abstractions/Stores/ITokenStoreKernel.cs | 47 +++++ .../Session/ResolvedRefreshSession.cs | 38 ++++ .../Contracts/Session/SessionRefreshResult.cs | 17 +- .../Token/RefreshTokenFailureReason.cs | 10 + .../Token/RefreshTokenValidationResult.cs | 31 +++ .../Domain/Token/StoredRefreshToken.cs | 19 ++ .../UAuthRefreshTokenResolver.cs | 83 ++++++++ .../Contracts/LoginResponse.cs | 12 ++ .../Contracts/LogoutResponse.cs | 7 + .../Endpoints/DefaultLoginEndpointHandler.cs | 20 +- .../Endpoints/DefaultLogoutEndpointHandler.cs | 43 +++++ .../Services/UAuthFlowService.cs | 181 +++++++++++++----- .../IUAuthUserManagementService.cs | 0 .../Abstractions/IUAuthUserProfileService.cs | 0 .../CodeBeam.UltimateAuth.Server.Users.csproj | 0 .../Extensions/.gitkeep | 0 .../Middlewares/.gitkeep | 0 .../Options/.gitkeep | 0 .../Services/.gitkeep | 0 .../Users/Models/AdminUserFilter.cs | 0 .../Users/Models/ChangePasswordRequest.cs | 0 .../Users/Models/ConfigureMfaRequest.cs | 0 .../Users/Models/ResetPasswordRequest.cs | 0 .../Users/Models/UpdateProfileRequest.cs | 0 .../Users/Models/UserDto.cs | 0 .../Users/Models/UserProfileDto.cs | 0 29 files changed, 474 insertions(+), 62 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IRefreshTokenResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreFactory.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ITokenStoreKernel.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenFailureReason.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/Token/StoredRefreshToken.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenResolver.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/LoginResponse.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Contracts/LogoutResponse.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLogoutEndpointHandler.cs rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Abstractions/IUAuthUserManagementService.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Abstractions/IUAuthUserProfileService.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/CodeBeam.UltimateAuth.Server.Users.csproj (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Extensions/.gitkeep (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Middlewares/.gitkeep (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Options/.gitkeep (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Services/.gitkeep (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Users/Models/AdminUserFilter.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Users/Models/ChangePasswordRequest.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Users/Models/ConfigureMfaRequest.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Users/Models/ResetPasswordRequest.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Users/Models/UpdateProfileRequest.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Users/Models/UserDto.cs (100%) rename src/{CodeBeam.UltimateAuth.AspNetCore => CodeBeam.UltimateAuth.Users}/Users/Models/UserProfileDto.cs (100%) 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/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/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/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/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/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.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/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index 171621c..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 at = request.At ?? DateTimeOffset.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 = at, - 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, - At = at + 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 ?? DateTimeOffset.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(request.TenantId, request.SessionId), ct); } public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default) { - var at = request.At ?? DateTimeOffset.UtcNow; + var now = request.At ?? DateTimeOffset.UtcNow; if (request.CurrentSessionId is null) - throw new InvalidOperationException( - "CurrentSessionId must be provided for logout-all operation."); + throw new InvalidOperationException("CurrentSessionId must be provided for logout-all operation."); var currentSessionId = request.CurrentSessionId.Value; - var validation = await _sessions.ValidateSessionAsync( - request.TenantId, - currentSessionId, - at); + var validation = await _queries.ValidateSessionAsync( + new SessionValidationContext + { + TenantId = request.TenantId, + SessionId = currentSessionId, + Now = now + }, + ct); - if (validation.IsValid || - validation.Session is null) + if (!validation.IsValid || validation.Session is null) throw new InvalidOperationException("Current session is not valid."); var userId = validation.Session.UserId; - ChainId? currentChainId = null; + ChainId? exceptChainId = null; if (request.ExceptCurrent) { - if (request.CurrentSessionId is null) - throw new InvalidOperationException("CurrentSessionId must be provided when ExceptCurrent is true."); - - currentChainId = await _sessions.ResolveChainIdAsync( + exceptChainId = await _queries.ResolveChainIdAsync( request.TenantId, - currentSessionId); + currentSessionId, + ct); - if (currentChainId is null) + if (exceptChainId is null) throw new InvalidOperationException("Current session chain could not be resolved."); } - await _sessions.RevokeAllChainsAsync(request.TenantId, userId, exceptChainId: currentChainId, at); + var authContext = AuthContext.System( + request.TenantId, + AuthOperation.Revoke, + now); + + await _orchestrator.ExecuteAsync( + authContext, + new RevokeAllChainsCommand( + userId, + exceptChainId), + ct); } public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default) @@ -135,9 +178,53 @@ public Task ReauthenticateAsync(ReauthRequest request, Cancellatio throw new NotImplementedException(); } - public Task RefreshSessionAsync(SessionRefreshRequest request, CancellationToken ct = default) + public async Task RefreshSessionAsync(SessionRefreshRequest request, CancellationToken ct = default) { - throw new NotImplementedException(); + var now = DateTimeOffset.UtcNow; + var resolved = await _refreshTokens.ResolveAsync(request.TenantId, request.RefreshToken, now, ct); + + if (resolved is null) + return SessionRefreshResult.Invalid(); + + if (!resolved.IsValid) + { + // TODO: Add reuse detection handling here + //if (resolved.IsReuseDetected) + //{ + // await _sessions.RevokeChainAsync( + // tenantId, + // resolved.Chain!.ChainId, + // now); + //} + + //return SessionRefreshResult.ReauthRequired(); + } + + var session = resolved.Session; + + var rotationContext = new SessionRotationContext + { + TenantId = request.TenantId, + CurrentSessionId = session.SessionId, + UserId = session.UserId, + Now = now + }; + + var authContext = AuthContext.ForAuthenticatedUser(request.TenantId, AuthOperation.Refresh, now, DeviceContext.From(session.Device)); + + var issuedSession = await _orchestrator.ExecuteAsync(authContext, new RotateSessionCommand(rotationContext), ct); + + var tokenContext = new TokenIssuanceContext + { + TenantId = request.TenantId, + UserId = session.UserId!.ToString()!, + SessionId = issuedSession.Session.SessionId + }; + + var accessToken = await _tokens.IssueAccessTokenAsync(tokenContext, ct); + var refreshToken = await _tokens.IssueRefreshTokenAsync(tokenContext, ct); + + return SessionRefreshResult.Success(accessToken, refreshToken); } public Task VerifyPkceAsync(PkceVerifyRequest request, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserManagementService.cs b/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserManagementService.cs rename to src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserManagementService.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserProfileService.cs b/src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserProfileService.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserProfileService.cs rename to src/CodeBeam.UltimateAuth.Users/Abstractions/IUAuthUserProfileService.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.Server.Users.csproj b/src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.Server.Users.csproj rename to src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Extensions/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Extensions/.gitkeep similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Extensions/.gitkeep rename to src/CodeBeam.UltimateAuth.Users/Extensions/.gitkeep diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Middlewares/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Middlewares/.gitkeep similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Middlewares/.gitkeep rename to src/CodeBeam.UltimateAuth.Users/Middlewares/.gitkeep diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Options/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Options/.gitkeep similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Options/.gitkeep rename to src/CodeBeam.UltimateAuth.Users/Options/.gitkeep diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Services/.gitkeep b/src/CodeBeam.UltimateAuth.Users/Services/.gitkeep similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Services/.gitkeep rename to src/CodeBeam.UltimateAuth.Users/Services/.gitkeep diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/AdminUserFilter.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/AdminUserFilter.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/AdminUserFilter.cs rename to src/CodeBeam.UltimateAuth.Users/Users/Models/AdminUserFilter.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ChangePasswordRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/ChangePasswordRequest.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ChangePasswordRequest.cs rename to src/CodeBeam.UltimateAuth.Users/Users/Models/ChangePasswordRequest.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ConfigureMfaRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/ConfigureMfaRequest.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ConfigureMfaRequest.cs rename to src/CodeBeam.UltimateAuth.Users/Users/Models/ConfigureMfaRequest.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ResetPasswordRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/ResetPasswordRequest.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ResetPasswordRequest.cs rename to src/CodeBeam.UltimateAuth.Users/Users/Models/ResetPasswordRequest.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UpdateProfileRequest.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/UpdateProfileRequest.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UpdateProfileRequest.cs rename to src/CodeBeam.UltimateAuth.Users/Users/Models/UpdateProfileRequest.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/UserDto.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs rename to src/CodeBeam.UltimateAuth.Users/Users/Models/UserDto.cs diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs b/src/CodeBeam.UltimateAuth.Users/Users/Models/UserProfileDto.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs rename to src/CodeBeam.UltimateAuth.Users/Users/Models/UserProfileDto.cs From 6f0ed48ea55a19bc13dbcbe23ecb716f5025f441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Thu, 18 Dec 2025 20:56:46 +0300 Subject: [PATCH 4/4] Change Slnx --- UltimateAuth.slnx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 18a8f98..8f20ba9 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -3,7 +3,7 @@ - +