diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 21efc31..18a8f98 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -3,7 +3,7 @@ - + diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserManagementService.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserManagementService.cs new file mode 100644 index 0000000..44c57f1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserManagementService.cs @@ -0,0 +1,28 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + /// + /// Administrative user management operations. + /// + public interface IUAuthUserManagementService + { + Task> GetByIdAsync( + TUserId userId, + CancellationToken ct = default); + + Task>> GetAllAsync( + CancellationToken ct = default); + + Task DisableAsync( + TUserId userId, + CancellationToken ct = default); + + Task EnableAsync( + TUserId userId, + CancellationToken ct = default); + + Task ResetPasswordAsync( + TUserId userId, + ResetPasswordRequest request, + CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserProfileService.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserProfileService.cs new file mode 100644 index 0000000..68dedea --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Abstractions/IUAuthUserProfileService.cs @@ -0,0 +1,23 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + /// + /// User self-service operations (profile, password, MFA). + /// + public interface IUAuthUserProfileService + { + Task> GetCurrentAsync( + CancellationToken ct = default); + + Task UpdateProfileAsync( + UpdateProfileRequest request, + CancellationToken ct = default); + + Task ChangePasswordAsync( + ChangePasswordRequest request, + CancellationToken ct = default); + + Task ConfigureMfaAsync( + ConfigureMfaRequest request, + CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.AspNetCore.csproj b/src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.Server.Users.csproj similarity index 100% rename from src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.AspNetCore.csproj rename to src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.Server.Users.csproj diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Handlers/.gitkeep b/src/CodeBeam.UltimateAuth.AspNetCore/Handlers/.gitkeep deleted file mode 100644 index 5f28270..0000000 --- a/src/CodeBeam.UltimateAuth.AspNetCore/Handlers/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/AdminUserFilter.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/AdminUserFilter.cs new file mode 100644 index 0000000..87cec2b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/AdminUserFilter.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class AdminUserFilter + { + public bool? IsActive { get; init; } + public bool? IsEmailConfirmed { get; init; } + + public string? Search { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ChangePasswordRequest.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ChangePasswordRequest.cs new file mode 100644 index 0000000..b53dfb6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ChangePasswordRequest.cs @@ -0,0 +1,13 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class ChangePasswordRequest + { + public required string CurrentPassword { get; init; } + public required string NewPassword { get; init; } + + /// + /// If true, other sessions will be revoked. + /// + public bool RevokeOtherSessions { get; init; } = true; + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ConfigureMfaRequest.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ConfigureMfaRequest.cs new file mode 100644 index 0000000..ff2b1d4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ConfigureMfaRequest.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class ConfigureMfaRequest + { + public bool Enable { get; init; } + + /// + /// Optional verification code when enabling MFA. + /// + public string? VerificationCode { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ResetPasswordRequest.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ResetPasswordRequest.cs new file mode 100644 index 0000000..672d176 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/ResetPasswordRequest.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class ResetPasswordRequest + { + public required string NewPassword { get; init; } + + /// + /// If true, all active sessions will be revoked. + /// + public bool RevokeSessions { get; init; } = true; + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UpdateProfileRequest.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UpdateProfileRequest.cs new file mode 100644 index 0000000..f7688c9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UpdateProfileRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class UpdateProfileRequest + { + public string? Username { get; init; } + public string? Email { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs new file mode 100644 index 0000000..8aa7cb5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserDto.cs @@ -0,0 +1,16 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class UserDto + { + public required TUserId UserId { get; init; } + + public string? Username { get; init; } + public string? Email { get; init; } + + public bool IsActive { get; init; } + public bool IsEmailConfirmed { get; init; } + + public DateTime CreatedAt { get; init; } + public DateTime? LastLoginAt { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs new file mode 100644 index 0000000..9b2496e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.AspNetCore/Users/Models/UserProfileDto.cs @@ -0,0 +1,14 @@ +namespace CodeBeam.UltimateAuth.Server.Users +{ + public sealed class UserProfileDto + { + public required TUserId UserId { get; init; } + + public string? Username { get; init; } + public string? Email { get; init; } + + public bool IsEmailConfirmed { get; init; } + + public DateTime CreatedAt { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs new file mode 100644 index 0000000..ce4905a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Provides an abstracted time source for the system. + /// Used to improve testability and ensure consistent time handling. + /// + public interface IClock + { + DateTime UtcNow { get; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenHasher.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenHasher.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs new file mode 100644 index 0000000..53246c1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs @@ -0,0 +1,13 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Securely hashes and verifies user passwords. + /// Designed for slow, adaptive, memory-hard algorithms + /// such as Argon2 or bcrypt. + /// + public interface IUAuthPasswordHasher + { + string Hash(string password); + bool Verify(string password, string hash); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs index f19db3e..726bbfb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Core.Abstractions @@ -8,6 +8,6 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions /// public interface ISessionIssuer { - Task> IssueAsync(SessionIssueContext context, UAuthSessionChain chain, CancellationToken cancellationToken = default); + Task> IssueAsync(AuthenticatedSessionContext context, ISessionChain chain, CancellationToken cancellationToken = default); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenIssuer.cs deleted file mode 100644 index 948bf3b..0000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ITokenIssuer.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contexts; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - /// - /// Issues access and refresh tokens according to the active auth mode. - /// Does not perform persistence or validation. - /// - public interface ITokenIssuer - { - Task IssueAccessTokenAsync(TokenIssueContext context, CancellationToken cancellationToken = default); - Task IssueRefreshTokenAsync(TokenIssueContext context, CancellationToken cancellationToken = default); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserAuthenticator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserAuthenticator.cs new file mode 100644 index 0000000..0708937 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserAuthenticator.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface IUserAuthenticator + { + Task> AuthenticateAsync(string? tenantId, string identifier, string secret, CancellationToken cancellationToken = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverter.cs similarity index 94% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverter.cs index a52f3ec..5564215 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverter.cs @@ -31,6 +31,7 @@ public interface IUserIdConverter /// Thrown when the input value cannot be parsed into a valid identifier. /// TUserId FromString(string value); + bool TryFromString(string value, out TUserId userId); /// /// Reconstructs a typed user identifier from its binary representation. @@ -41,5 +42,6 @@ public interface IUserIdConverter /// Thrown when the input binary value cannot be parsed into a valid identifier. /// TUserId FromBytes(byte[] binary); + bool TryFromBytes(byte[] binary, out TUserId userId); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverterResolver.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/IUserIdConverterResolver.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdFactory.cs new file mode 100644 index 0000000..b5d2715 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdFactory.cs @@ -0,0 +1,16 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Responsible for creating new user identifiers. + /// This abstraction allows UltimateAuth to remain + /// independent from the concrete user ID type. + /// + /// User identifier type. + public interface IUserIdFactory + { + /// + /// Creates a new unique user identifier. + /// + TUserId Create(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs deleted file mode 100644 index c9ba215..0000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/ISessionService.cs +++ /dev/null @@ -1,93 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Models; - -namespace CodeBeam.UltimateAuth.Core.Abstractions -{ - /// - /// Provides high-level session lifecycle operations such as creation, refresh, validation, and revocation. - /// - /// The type used to uniquely identify the user. - public interface ISessionService - { - /// - /// Creates a new login session for the specified user. - /// - /// - /// The tenant identifier. Use null for single-tenant applications. - /// - /// The user associated with the session. - /// Information about the device initiating the session. - /// Optional metadata describing the session context. - /// The current UTC timestamp. - /// - /// A result containing the newly created session, chain, and session root. - /// - Task> CreateLoginSessionAsync(string? tenantId, TUserId userId, DeviceInfo deviceInfo, SessionMetadata? metadata, DateTime now); - - /// - /// Rotates the specified session and issues a new one while preserving the session chain. - /// - /// The tenant identifier, or null. - /// The active session identifier to be refreshed. - /// The current UTC timestamp. - /// - /// A result containing the refreshed session and updated chain. - /// - /// - /// Thrown if the session, its chain, or the user's session root is invalid. - /// - Task> RefreshSessionAsync(string? tenantId, AuthSessionId currentSessionId,DateTime now); - - /// - /// Revokes a single session, preventing further use. - /// - /// The tenant identifier, or null. - /// The session identifier to revoke. - /// The UTC timestamp of the revocation. - Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); - - /// - /// Revokes an entire session chain (device-level logout). - /// - /// The tenant identifier, or null. - /// The session chain identifier to revoke. - /// The UTC timestamp of the revocation. - Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at); - - /// - /// Revokes the user's session root, invalidating all existing sessions across all chains. - /// - /// The tenant identifier, or null. - /// The user whose root should be revoked. - /// The UTC timestamp of the revocation. - Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at); - - /// - /// Validates a session and evaluates its current state, including expiration, revocation, and security version alignment. - /// - /// The tenant identifier, or null. - /// The session identifier to validate. - /// The current UTC timestamp. - /// - /// A detailed validation result describing the session, chain, root, - /// and computed session state. - /// - Task> ValidateSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime now); - - /// - /// Retrieves all session chains belonging to the specified user. - /// - /// The tenant identifier, or null. - /// The user whose session chains are requested. - /// A read-only list of session chains. - Task>> GetChainsAsync(string? tenantId, TUserId userId); - - /// - /// Retrieves all sessions belonging to a specific session chain. - /// - /// The tenant identifier, or null. - /// The session chain identifier. - /// A read-only list of sessions contained within the chain. - Task>> GetSessionsAsync(string? tenantId, ChainId chainId); - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthFlowService.cs new file mode 100644 index 0000000..530bacd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthFlowService.cs @@ -0,0 +1,33 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Handles authentication flows such as login, + /// logout, session refresh and reauthentication. + /// + public interface IUAuthFlowService + { + Task LoginAsync(LoginRequest request, CancellationToken ct = default); + + Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default); + + Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default); + + Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default); + + Task LogoutAsync(LogoutRequest request, CancellationToken ct = default); + + Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default); + + Task RefreshSessionAsync(SessionRefreshRequest request, CancellationToken ct = default); + + Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default); + + Task CreatePkceChallengeAsync(PkceCreateRequest request, CancellationToken ct = default); + + Task VerifyPkceAsync(PkceVerifyRequest request, CancellationToken ct = default); + + Task ConsumePkceAsync(PkceConsumeRequest request, CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs new file mode 100644 index 0000000..3e3e0ac --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthService.cs @@ -0,0 +1,15 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// High-level facade for UltimateAuth. + /// Provides access to authentication flows, + /// session lifecycle and user operations. + /// + public interface IUAuthService + { + IUAuthFlowService Flow { get; } + IUAuthSessionService Sessions { get; } + IUAuthTokenService Tokens { get; } + IUAuthUserService Users { get; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs new file mode 100644 index 0000000..25ad4f5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthSessionService.cs @@ -0,0 +1,33 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Provides high-level session lifecycle operations such as creation, refresh, validation, and revocation. + /// + /// The type used to uniquely identify the user. + public interface IUAuthSessionService + { + Task> ValidateSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); + + Task>> GetChainsAsync(string? tenantId, TUserId userId); + + Task>> GetSessionsAsync(string? tenantId, ChainId chainId); + + Task?> GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId); + + Task RevokeSessionAsync(string? tenantId, AuthSessionId sessionId, DateTime at); + + Task RevokeChainAsync(string? tenantId, ChainId chainId, DateTime at); + + Task ResolveChainIdAsync(string? tenantId, AuthSessionId sessionId); + + Task RevokeAllChainsAsync(string? tenantId, TUserId userId, ChainId? exceptChainId, DateTime at); + + // Hard revoke - admin + Task RevokeRootAsync(string? tenantId, TUserId userId, DateTime at); + + Task> IssueSessionAfterAuthenticationAsync(string? tenantId, AuthenticatedSessionContext context, CancellationToken cancellationToken = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthTokenService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthTokenService.cs new file mode 100644 index 0000000..bca0bd6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthTokenService.cs @@ -0,0 +1,27 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Issues, refreshes and validates access and refresh tokens. + /// Stateless or hybrid depending on auth mode. + /// + public interface IUAuthTokenService + { + /// + /// Issues access (and optionally refresh) tokens + /// for a validated session. + /// + Task CreateTokensAsync(TokenIssueContext context, CancellationToken cancellationToken = default); + + /// + /// Refreshes tokens using a refresh token. + /// + Task RefreshAsync(TokenRefreshContext context, CancellationToken cancellationToken = default); + + /// + /// Validates an access token (JWT or opaque). + /// + Task> ValidateAsync(string token, TokenType type, CancellationToken cancellationToken = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs new file mode 100644 index 0000000..ee74667 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Services/IUAuthUserService.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Minimal user operations required for authentication. + /// Does NOT include role or permission management. + /// For user management, CodeBeam.UltimateAuth.Users package is recommended. + /// + public interface IUAuthUserService + { + Task RegisterAsync(RegisterUserRequest request, CancellationToken cancellationToken = default); + + Task DeleteAsync(TUserId userId, CancellationToken cancellationToken = default); + + Task ValidateCredentialsAsync(ValidateCredentialsRequest request, CancellationToken cancellationToken = default); + + Task> AuthenticateAsync(string? tenantId, string identifier, string secret, CancellationToken cancellationToken = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IOpaqueTokenStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IOpaqueTokenStore.cs new file mode 100644 index 0000000..2f5e141 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IOpaqueTokenStore.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface IOpaqueTokenStore + { + Task FindByHashAsync( + string tokenHash, + CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index e25ba9c..5b65e7d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Core.Abstractions diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs index 9d8763b..d398eb1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreKernel.cs @@ -56,13 +56,6 @@ public interface ISessionStoreKernel /// The chain to save. Task SaveChainAsync(string? tenantId, ISessionChain chain); - /// - /// Updates an existing session chain, typically after session rotation or revocation. Implementations must preserve atomicity. - /// - /// The tenant identifier, or null. - /// The updated session chain. - Task UpdateChainAsync(string? tenantId, ISessionChain chain); - /// /// Marks the entire session chain as revoked, invalidating all associated sessions for the device or app family. /// @@ -135,6 +128,11 @@ public interface ISessionStoreKernel /// The session identifier. /// The chain identifier or null. Task GetChainIdBySessionAsync(string? tenantId, AuthSessionId sessionId); - } + /// + /// Executes multiple store operations as a single atomic unit. + /// Implementations must ensure transactional consistency where supported. + /// + Task ExecuteAsync(Func action); + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs similarity index 74% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStore.cs rename to src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs index fcb268b..53f78c5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUAuthUserStore.cs @@ -1,18 +1,20 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Core.Abstractions { /// /// Provides minimal user lookup and security metadata required for authentication. /// This store does not manage user creation, claims, or profile data — these belong /// to higher-level application services outside UltimateAuth. /// - public interface IUserStore + public interface IUAuthUserStore { - /// - /// Retrieves a user by identifier. Returns null if no such user exists. - /// - /// The identifier of the user. - /// The user instance or null if not found. - Task?> FindByIdAsync(TUserId userId); + Task?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken token = default); + + Task?> FindByUsernameAsync(string? tenantId, + string username, + CancellationToken ct = default); /// /// Retrieves a user by a login credential such as username or email. @@ -33,7 +35,7 @@ public interface IUserStore /// /// Updates the password hash for the specified user. This method is invoked by - /// password management services and not by . + /// password management services and not by . /// /// The user identifier. /// The new password hash value. @@ -54,5 +56,17 @@ public interface IUserStore /// /// The user identifier. Task IncrementSecurityVersionAsync(TUserId userId); + + Task ExistsByUsernameAsync( + string username, + CancellationToken ct = default); + + Task CreateAsync( + UserRecord user, + CancellationToken ct = default); + + Task DeleteAsync( + TUserId userId, + CancellationToken ct = default); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs index 06e7a0d..23f8551 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IUserStoreFactory.cs @@ -16,11 +16,11 @@ public interface IUserStoreFactory /// in single-tenant deployments. /// /// - /// An implementation capable of user lookup and security metadata retrieval. + /// An implementation capable of user lookup and security metadata retrieval. /// /// /// Thrown if no user store implementation has been registered for the given user ID type. /// - IUserStore Create(string tenantId); + IUAuthUserStore Create(string tenantId); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/ITokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/ITokenValidator.cs new file mode 100644 index 0000000..d2010fb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/ITokenValidator.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + /// + /// Validates access tokens (JWT or opaque) and resolves + /// the authenticated user context. + /// + public interface ITokenValidator + { + Task> ValidateAsync( + string token, + TokenType type, + CancellationToken ct = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj index 488b584..c03f964 100644 --- a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj +++ b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj @@ -8,9 +8,9 @@ - - - + + + diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/SessionIssueContext.cs b/src/CodeBeam.UltimateAuth.Core/Contexts/SessionIssueContext.cs deleted file mode 100644 index d75473c..0000000 --- a/src/CodeBeam.UltimateAuth.Core/Contexts/SessionIssueContext.cs +++ /dev/null @@ -1,23 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Contexts -{ - /// - /// Represents the context in which a session is issued - /// (login, refresh, reauthentication). - /// - public sealed class SessionIssueContext - { - public required TUserId UserId { get; init; } - public string? TenantId { get; init; } - - public required long SecurityVersion { get; init; } - - public DeviceInfo Device { get; init; } - public IReadOnlyDictionary? ClaimsSnapshot { get; init; } - - public DateTime Now { get; init; } = DateTime.UtcNow; - - public ChainId? ChainId { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/TokenIssueContext.cs b/src/CodeBeam.UltimateAuth.Core/Contexts/TokenIssueContext.cs deleted file mode 100644 index 782d6a4..0000000 --- a/src/CodeBeam.UltimateAuth.Core/Contexts/TokenIssueContext.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Security.Claims; - -namespace CodeBeam.UltimateAuth.Core.Contexts -{ - public sealed class TokenIssueContext - { - public required string UserId { get; init; } - public required string TenantId { get; init; } - - public IReadOnlyCollection Claims { get; init; } = Array.Empty(); - - public string? SessionId { get; init; } - - public DateTimeOffset IssuedAt { get; init; } = DateTimeOffset.UtcNow; - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs new file mode 100644 index 0000000..6126e7d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record ExternalLoginRequest + { + public string? TenantId { get; init; } + public string Provider { get; init; } = default!; + public string ExternalToken { get; init; } = default!; + public string? DeviceId { get; init; } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuation.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuation.cs new file mode 100644 index 0000000..ec5fb02 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuation.cs @@ -0,0 +1,20 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record LoginContinuation + { + /// + /// Gets the type of login continuation required. + /// + public LoginContinuationType Type { get; init; } + + /// + /// Opaque continuation token used to resume the login flow. + /// + public string ContinuationToken { get; init; } = default!; + + /// + /// Optional hint for UX (e.g. "Enter MFA code", "Verify device"). + /// + public string? Hint { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuationType.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuationType.cs new file mode 100644 index 0000000..662fbef --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuationType.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum LoginContinuationType + { + Mfa, + Pkce, + External + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs new file mode 100644 index 0000000..97a75f0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + 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 DeviceInfo DeviceInfo { get; init; } + public IReadOnlyDictionary? Metadata { get; init; } + + /// + /// Hint to request access/refresh tokens when the server mode supports it. + /// Server policy may still ignore this. + /// + public bool RequestTokens { get; init; } + + // Optional + public ChainId? ChainId { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs new file mode 100644 index 0000000..cc0d44f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs @@ -0,0 +1,38 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record LoginResult + { + public LoginStatus Status { get; init; } + public AuthSessionId? SessionId { get; init; } + public AccessToken? AccessToken { get; init; } + public RefreshToken? RefreshToken { get; init; } + public LoginContinuation? Continuation { get; init; } + + // Helpers + public bool IsSuccess => Status == LoginStatus.Success; + public bool RequiresContinuation => Continuation is not null; + public bool RequiresMfa => Continuation?.Type == LoginContinuationType.Mfa; + public bool RequiresPkce => Continuation?.Type == LoginContinuationType.Pkce; + + public static LoginResult Failed() => new() { Status = LoginStatus.Failed }; + + public static LoginResult Success(AuthSessionId sessionId, AuthTokens? tokens = null) + => new() + { + Status = LoginStatus.Success, + SessionId = sessionId, + AccessToken = tokens?.AccessToken, + RefreshToken = tokens?.RefreshToken + }; + + public static LoginResult Continue(LoginContinuation continuation) + => new() + { + Status = LoginStatus.RequiresContinuation, + Continuation = continuation + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs new file mode 100644 index 0000000..94a3902 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum LoginStatus + { + Success, + RequiresContinuation, + Failed + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs new file mode 100644 index 0000000..b1d2565 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record ReauthRequest + { + public string? TenantId { get; init; } + public AuthSessionId SessionId { get; init; } + public string Secret { get; init; } = default!; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs new file mode 100644 index 0000000..d14eb10 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record ReauthResult + { + public bool Success { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs new file mode 100644 index 0000000..7bfa2da --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed class LogoutAllRequest + { + public string? TenantId { get; init; } + + /// + /// The current session initiating the logout-all operation. + /// Used to resolve the active chain when ExceptCurrent is true. + /// + public AuthSessionId? CurrentSessionId { get; init; } + + /// + /// If true, the current session will NOT be revoked. + /// + public bool ExceptCurrent { get; init; } + + public DateTime? At { get; init; } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs new file mode 100644 index 0000000..90e6973 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + 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; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs new file mode 100644 index 0000000..86af91a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record BeginMfaRequest + { + public string MfaToken { get; init; } = default!; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs new file mode 100644 index 0000000..5d575d0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record CompleteMfaRequest + { + public string ChallengeId { get; init; } = default!; + public string Code { get; init; } = default!; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs new file mode 100644 index 0000000..9bb085c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record MfaChallengeResult + { + public string ChallengeId { get; init; } = default!; + public string Method { get; init; } = default!; // totp, sms, email etc. + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceChallengeResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceChallengeResult.cs new file mode 100644 index 0000000..1a4d986 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceChallengeResult.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record PkceChallengeResult + { + public string Challenge { get; init; } = default!; + public string Method { get; init; } = "S256"; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceConsumeRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceConsumeRequest.cs new file mode 100644 index 0000000..153e865 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceConsumeRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record PkceConsumeRequest + { + public string Challenge { get; init; } = default!; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCreateRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCreateRequest.cs new file mode 100644 index 0000000..bd8eb88 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCreateRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record PkceCreateRequest + { + public string ClientId { get; init; } = default!; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerificationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerificationResult.cs new file mode 100644 index 0000000..c094b0a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerificationResult.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record PkceVerificationResult + { + public bool IsValid { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerifyRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerifyRequest.cs new file mode 100644 index 0000000..9a1d588 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceVerifyRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record PkceVerifyRequest + { + public string Challenge { get; init; } = default!; + public string Verifier { get; init; } = default!; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs new file mode 100644 index 0000000..6422fbb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthenticatedSessionContext.cs @@ -0,0 +1,31 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + /// + /// Represents the context in which a session is issued + /// (login, refresh, reauthentication). + /// + 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 ClaimsSnapshot? Claims { get; init; } + public SessionMetadata Metadata { get; init; } + + /// + /// Optional chain identifier. + /// If null, a new chain will be created. + /// If provided, session will be issued under the existing chain. + /// + public ChainId? ChainId { get; init; } + + /// + /// Indicates that authentication has already been completed. + /// This context MUST NOT be constructed from raw credentials. + /// + public bool IsAuthenticated { get; init; } = true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedSession.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedSession.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs index 157663a..cc2f0f8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contexts +namespace CodeBeam.UltimateAuth.Core.Contracts { /// /// Represents the result of a session issuance operation. diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/SessionContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs similarity index 94% rename from src/CodeBeam.UltimateAuth.Server/Sessions/SessionContext.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs index 3aad385..03af37b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/SessionContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Core.Contracts { /// /// Lightweight session context resolved from the incoming request. diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs new file mode 100644 index 0000000..9343883 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshRequest.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record SessionRefreshRequest + { + public string? TenantId { get; init; } + public string RefreshToken { get; init; } = default!; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs new file mode 100644 index 0000000..d207f59 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record SessionRefreshResult + { + public AccessToken AccessToken { get; init; } = default!; + public RefreshToken? RefreshToken { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Models/SessionResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs similarity index 97% rename from src/CodeBeam.UltimateAuth.Core/Models/SessionResult.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs index 5dbfb06..cb43f4e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Models/SessionResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionResult.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Models +namespace CodeBeam.UltimateAuth.Core.Contracts { // TODO: IsNewChain, IsNewRoot flags? /// diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs new file mode 100644 index 0000000..2510afa --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + 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 DeviceInfo Device { get; init; } + public ClaimsSnapshot Claims { get; init; } + public SessionMetadata Metadata { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/SessionStoreContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs similarity index 96% rename from src/CodeBeam.UltimateAuth.Core/Contexts/SessionStoreContext.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs index fa5a426..78910d4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contexts/SessionStoreContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contexts +namespace CodeBeam.UltimateAuth.Core.Contracts { /// /// Context information required by the session store when diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs new file mode 100644 index 0000000..aa8b3dd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record SessionValidationContext + { + public string? TenantId { get; init; } + public AuthSessionId SessionId { get; init; } + public DateTime Now { get; init; } + public DeviceInfo Device { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs new file mode 100644 index 0000000..26e9020 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs @@ -0,0 +1,44 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed class SessionValidationResult + { + public SessionState State { get; } + public ISession? Session { get; } + public ISessionChain? Chain { get; } + public ISessionRoot? Root { get; } + + private SessionValidationResult( + SessionState state, + ISession? session, + ISessionChain? chain, + ISessionRoot? root) + { + State = state; + Session = session; + Chain = chain; + Root = root; + } + + public bool IsValid => State == SessionState.Active; + + public static SessionValidationResult Active( + ISession session, + ISessionChain chain, + ISessionRoot root) + => new( + SessionState.Active, + session, + chain, + root); + + public static SessionValidationResult Invalid( + SessionState state) + => new( + state, + session: null, + chain: null, + root: null); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedAccessToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs similarity index 77% rename from src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedAccessToken.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs index 32d37e6..3284350 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedAccessToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs @@ -1,20 +1,21 @@ -namespace CodeBeam.UltimateAuth.Core.Contexts +namespace CodeBeam.UltimateAuth.Core.Contracts { /// /// Represents an issued access token (JWT or opaque). /// - public sealed class IssuedAccessToken + public sealed class AccessToken { /// /// The actual token value sent to the client. /// public required string Token { get; init; } + // TODO: TokenKind enum? /// /// Token type: "jwt" or "opaque". /// Used for diagnostics and middleware behavior. /// - public required string TokenType { get; init; } + public TokenType Type { get; init; } /// /// Expiration time of the token. @@ -25,5 +26,7 @@ public sealed class IssuedAccessToken /// Optional session id this token is bound to (Hybrid / SemiHybrid). /// public string? SessionId { get; init; } + + public string? Scope { get; init; } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs new file mode 100644 index 0000000..344fedd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs @@ -0,0 +1,17 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + /// + /// Represents a set of authentication tokens issued as a result of a successful login. + /// This model is intentionally extensible to support additional token types in the future. + /// + public sealed record AuthTokens + { + /// + /// The issued access token. + /// Always present when is returned. + /// + public AccessToken AccessToken { get; init; } = default!; + + public RefreshToken? RefreshToken { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/OpaqueTokenRecord.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/OpaqueTokenRecord.cs new file mode 100644 index 0000000..ed13a6a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/OpaqueTokenRecord.cs @@ -0,0 +1,18 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed class OpaqueTokenRecord + { + public string TokenHash { get; init; } = default!; + public string UserId { get; init; } = default!; + public string? TenantId { get; init; } + public AuthSessionId? SessionId { get; init; } + public DateTimeOffset ExpiresAt { get; init; } + public bool IsRevoked { get; init; } + public DateTimeOffset? RevokedAt { get; init; } + public IReadOnlyCollection Claims { get; init; } = Array.Empty(); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedRefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs similarity index 86% rename from src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedRefreshToken.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs index 1f64804..1e9d87a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contexts/Issued/IssuedRefreshToken.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshToken.cs @@ -1,10 +1,10 @@ -namespace CodeBeam.UltimateAuth.Core.Contexts +namespace CodeBeam.UltimateAuth.Core.Contracts { /// /// Represents an issued refresh token. /// Always opaque and hashed at rest. /// - public sealed class IssuedRefreshToken + public sealed class RefreshToken { /// /// Plain refresh token value (returned to client once). diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs new file mode 100644 index 0000000..96ce78b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs @@ -0,0 +1,16 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum TokenInvalidReason + { + Invalid, + Expired, + Revoked, + Malformed, + SignatureInvalid, + AudienceMismatch, + IssuerMismatch, + MissingSubject, + Unknown, + NotImplemented + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs new file mode 100644 index 0000000..ad3dc76 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssueContext.cs @@ -0,0 +1,11 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record TokenIssueContext + { + public string? TenantId { get; init; } + public ISession Session { get; init; } = default!; + public DateTime Now { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs new file mode 100644 index 0000000..9507442 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs @@ -0,0 +1,9 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record TokenRefreshContext + { + public string? TenantId { get; init; } + + public string RefreshToken { get; init; } = default!; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs new file mode 100644 index 0000000..dc94f72 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public enum TokenType + { + Opaque, + Jwt, + Unknown + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs new file mode 100644 index 0000000..b8e7c29 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs @@ -0,0 +1,67 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed record TokenValidationResult + { + public bool IsValid { get; init; } + public TokenType Type { get; init; } + public string? TenantId { get; init; } + public TUserId? UserId { get; init; } + public AuthSessionId? SessionId { get; init; } + public IReadOnlyCollection Claims { get; init; } = Array.Empty(); + public TokenInvalidReason? InvalidReason { get; init; } + public DateTime? ExpiresAt { get; set; } + + private TokenValidationResult( + bool isValid, + TokenType type, + string? tenantId, + TUserId? userId, + AuthSessionId? sessionId, + IReadOnlyCollection? claims, + TokenInvalidReason? invalidReason, + DateTime? expiresAt + ) + { + IsValid = isValid; + TenantId = tenantId; + UserId = userId; + SessionId = sessionId; + Claims = claims ?? Array.Empty(); + InvalidReason = invalidReason; + ExpiresAt = expiresAt; + } + + public static TokenValidationResult Valid( + TokenType type, + string? tenantId, + TUserId userId, + AuthSessionId? sessionId, + IReadOnlyCollection claims, + DateTime? expiresAt) + => new( + isValid: true, + type, + tenantId, + userId, + sessionId, + claims, + invalidReason: null, + expiresAt + ); + + public static TokenValidationResult Invalid(TokenType type, TokenInvalidReason reason) + => new( + isValid: false, + type, + tenantId: null, + userId: default, + sessionId: null, + claims: null, + invalidReason: reason, + expiresAt: null + ); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/RegisterUserRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/RegisterUserRequest.cs new file mode 100644 index 0000000..a5565b9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/RegisterUserRequest.cs @@ -0,0 +1,30 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + /// + /// Request to register a new user with credentials. + /// + public sealed class RegisterUserRequest + { + /// + /// Unique user identifier (username, email, or external id). + /// Interpretation is application-specific. + /// + public required string Identifier { get; init; } + + /// + /// Plain-text password. + /// Will be hashed by the configured password hasher. + /// + public required string Password { get; init; } + + /// + /// Optional tenant identifier. + /// + public string? TenantId { get; init; } + + /// + /// Optional initial claims or metadata. + /// + public IReadOnlyDictionary? Metadata { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs new file mode 100644 index 0000000..64e2c34 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs @@ -0,0 +1,26 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + public sealed class UserAuthenticationResult + { + public bool Succeeded { get; init; } + + public TUserId? UserId { get; init; } + + public ClaimsSnapshot? Claims { get; init; } + + public bool RequiresMfa { get; init; } + + public static UserAuthenticationResult Fail() => new() { Succeeded = false }; + + public static UserAuthenticationResult Success(TUserId userId, ClaimsSnapshot claims, bool requiresMfa = false) + => new() + { + Succeeded = true, + UserId = userId, + Claims = claims, + RequiresMfa = requiresMfa + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contexts/UserContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs similarity index 74% rename from src/CodeBeam.UltimateAuth.Core/Contexts/UserContext.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs index 34f30a2..0c87265 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contexts/UserContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs @@ -1,6 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contexts +namespace CodeBeam.UltimateAuth.Core.Contracts { public sealed class UserContext { diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/ValidateCredentialsRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/ValidateCredentialsRequest.cs new file mode 100644 index 0000000..fc1dd7e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/ValidateCredentialsRequest.cs @@ -0,0 +1,24 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts +{ + /// + /// Request to validate user credentials. + /// Used during login flows. + /// + public sealed class ValidateCredentialsRequest + { + /// + /// User identifier (same value used during registration). + /// + public required string Identifier { get; init; } + + /// + /// Plain-text password provided by the user. + /// + public required string Password { get; init; } + + /// + /// Optional tenant identifier. + /// + public string? TenantId { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs new file mode 100644 index 0000000..9cf5ad5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs @@ -0,0 +1,69 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + public sealed class ClaimsSnapshot + { + private readonly IReadOnlyDictionary _claims; + + public ClaimsSnapshot(IReadOnlyDictionary claims) + { + _claims = new Dictionary(claims); + } + + public IReadOnlyDictionary AsDictionary() => _claims; + + public bool TryGet(string type, out string value) => _claims.TryGetValue(type, out value); + + public string? Get(string type) + => _claims.TryGetValue(type, out var value) + ? value + : null; + + public static ClaimsSnapshot Empty { get; } = new ClaimsSnapshot(new Dictionary()); + + public override bool Equals(object? obj) + { + if (obj is not ClaimsSnapshot other) + return false; + + if (_claims.Count != other._claims.Count) + return false; + + foreach (var kv in _claims) + { + if (!other._claims.TryGetValue(kv.Key, out var v)) + return false; + + if (!string.Equals(kv.Value, v, StringComparison.Ordinal)) + return false; + } + + return true; + } + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + foreach (var kv in _claims.OrderBy(x => x.Key)) + { + hash = hash * 23 + kv.Key.GetHashCode(); + hash = hash * 23 + kv.Value.GetHashCode(); + } + return hash; + } + } + + public static ClaimsSnapshot From(params (string Type, string Value)[] claims) + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var (type, value) in claims) + dict[type] = value; + + return new ClaimsSnapshot(dict); + } + + // TODO: Add ToClaimsPrincipal and FromClaimsPrincipal methods + + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs index ca204c3..c70c7e5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/DeviceInfo.cs @@ -7,6 +7,11 @@ /// public sealed class DeviceInfo { + /// + /// Gets the unique identifier for the device. + /// + public string DeviceId { get; init; } = default!; + /// /// Gets the high-level platform identifier, such as web, mobile, /// tablet or iot. @@ -55,6 +60,34 @@ public sealed class DeviceInfo /// Gets optional custom metadata supplied by the application. /// Allows additional device attributes not covered by standard fields. /// - public Dictionary? Custom { get; init; } + public Dictionary? Custom { get; init; } + + public static DeviceInfo Unknown { get; } = new() + { + DeviceId = "unknown", + Platform = null, + Browser = null, + IpAddress = null, + UserAgent = null, + IsTrusted = null + }; + + /// + /// Determines whether the current device information matches the specified device information based on device + /// identifiers. + /// + /// The device information to compare with the current instance. Cannot be null. + /// true if the device identifiers are equal; otherwise, false. + public bool Matches(DeviceInfo other) + { + if (other is null) + return false; + + if (DeviceId != other.DeviceId) + return false; + + // TODO: UA / IP drift policy + return true; + } } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs index 349ae7e..232e13a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISession.cs @@ -56,6 +56,8 @@ public interface ISession /// DeviceInfo Device { get; } + ClaimsSnapshot Claims { get; } + /// /// Gets session-scoped metadata used for application-specific extensions, /// such as tenant data, app version, locale, or CSRF tokens. @@ -69,5 +71,9 @@ public interface ISession /// Current timestamp used for comparisons. /// The evaluated of this session. SessionState GetState(DateTime now); + + bool ShouldUpdateLastSeen(DateTime now); + ISession Touch(DateTime now); + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs index e408b17..358beb8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ISessionChain.cs @@ -35,7 +35,7 @@ public interface ISessionChain /// Useful for offline clients, WASM apps, and environments where /// full user lookup cannot be performed on each request. /// - IReadOnlyDictionary? ClaimsSnapshot { get; } + ClaimsSnapshot ClaimsSnapshot { get; } /// /// Gets the identifier of the currently active authentication session, if one exists. @@ -53,6 +53,10 @@ public interface ISessionChain /// Gets the timestamp when the chain was revoked, if applicable. /// DateTime? RevokedAt { get; } + + ISessionChain AttachSession(AuthSessionId sessionId); + ISessionChain RotateSession(AuthSessionId sessionId); + ISessionChain Revoke(DateTime at); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs index 20fe7fe..ca81551 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs @@ -27,12 +27,6 @@ public sealed class SessionMetadata /// public string? Locale { get; init; } - /// - /// Gets the tenant identifier attached to this session, if applicable. - /// This value may override or complement root-level multi-tenant resolution. - /// - public string? TenantId { get; init; } - /// /// Gets a Cross-Site Request Forgery token or other session-scoped secret /// used for request integrity validation in web applications. diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs index 0faaf75..95a6af0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs @@ -6,39 +6,12 @@ /// public enum SessionState { - /// - /// The session is valid, not expired, not revoked, and its security version - /// matches the user's current security version. - /// - Active = 0, - - /// - /// The session has passed its expiration time and is no longer valid. - /// - Expired = 1, - - /// - /// The session was explicitly revoked by user action or administrative control. - /// - Revoked = 2, - - /// - /// The session's parent chain has been revoked, typically representing a - /// device-level logout or device ban. - /// - ChainRevoked = 3, - - /// - /// The user's entire session root has been revoked. This invalidates all - /// chains and sessions immediately across all devices. - /// - RootRevoked = 4, - - /// - /// The session's stored SecurityVersionAtCreation does not match the user's - /// current security version, indicating a password reset, MFA reset, - /// or other critical security event. - /// - SecurityVersionMismatch = 5 + Active, + Expired, + Revoked, + NotFound, + Invalid, + SecurityMismatch, + DeviceMismatch } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index 07dab4e..a56fcd2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -12,20 +12,22 @@ public sealed class UAuthSession : ISession public DateTime? RevokedAt { get; } public long SecurityVersionAtCreation { get; } public DeviceInfo Device { get; } + public ClaimsSnapshot Claims { get; } public SessionMetadata Metadata { get; } private UAuthSession( - AuthSessionId sessionId, - string? tenantId, - TUserId userId, - DateTime createdAt, - DateTime expiresAt, - DateTime? lastSeenAt, - bool isRevoked, - DateTime? revokedAt, - long securityVersionAtCreation, - DeviceInfo device, - SessionMetadata metadata) + AuthSessionId sessionId, + string? tenantId, + TUserId userId, + DateTime createdAt, + DateTime expiresAt, + DateTime? lastSeenAt, + bool isRevoked, + DateTime? revokedAt, + long securityVersionAtCreation, + DeviceInfo device, + ClaimsSnapshot claims, + SessionMetadata metadata) { SessionId = sessionId; TenantId = tenantId; @@ -37,6 +39,7 @@ private UAuthSession( RevokedAt = revokedAt; SecurityVersionAtCreation = securityVersionAtCreation; Device = device; + Claims = claims; Metadata = metadata; } @@ -46,11 +49,11 @@ public static UAuthSession Create( TUserId userId, DateTime now, DateTime expiresAt, - long securityVersion, DeviceInfo device, + ClaimsSnapshot claims, SessionMetadata metadata) { - return new UAuthSession( + return new( sessionId, tenantId, userId, @@ -59,25 +62,59 @@ public static UAuthSession Create( lastSeenAt: now, isRevoked: false, revokedAt: null, - securityVersionAtCreation: securityVersion, + securityVersionAtCreation: 0, device: device, + claims: claims, metadata: metadata ); } - public UAuthSession WithLastSeen(DateTime now) + public UAuthSession WithSecurityVersion(long version) { + if (SecurityVersionAtCreation == version) + return this; + return new UAuthSession( SessionId, TenantId, UserId, CreatedAt, ExpiresAt, - lastSeenAt: now, + LastSeenAt, + IsRevoked, + RevokedAt, + version, + Device, + Claims, + Metadata + ); + } + + public bool ShouldUpdateLastSeen(DateTime now) + { + if (LastSeenAt is null) + return true; + + return (now - LastSeenAt.Value) >= TimeSpan.FromMinutes(1); + } + + public ISession Touch(DateTime now) + { + if (!ShouldUpdateLastSeen(now)) + return this; + + return new UAuthSession( + SessionId, + TenantId, + UserId, + CreatedAt, + ExpiresAt, + now, IsRevoked, RevokedAt, SecurityVersionAtCreation, Device, + Claims, Metadata ); } @@ -86,17 +123,18 @@ public UAuthSession Revoke(DateTime at) { if (IsRevoked) return this; - return new UAuthSession( + return new( SessionId, TenantId, UserId, CreatedAt, ExpiresAt, LastSeenAt, - isRevoked: true, - revokedAt: at, + true, + at, SecurityVersionAtCreation, Device, + Claims, Metadata ); } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index c775877..1afa3aa 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -7,7 +7,7 @@ public sealed class UAuthSessionChain : ISessionChain public TUserId UserId { get; } public int RotationCount { get; } public long SecurityVersionAtCreation { get; } - public IReadOnlyDictionary? ClaimsSnapshot { get; } + public ClaimsSnapshot ClaimsSnapshot { get; } public AuthSessionId? ActiveSessionId { get; } public bool IsRevoked { get; } public DateTime? RevokedAt { get; } @@ -18,7 +18,7 @@ private UAuthSessionChain( TUserId userId, int rotationCount, long securityVersionAtCreation, - IReadOnlyDictionary? claimsSnapshot, + ClaimsSnapshot claimsSnapshot, AuthSessionId? activeSessionId, bool isRevoked, DateTime? revokedAt) @@ -39,7 +39,7 @@ public static UAuthSessionChain Create( string? tenantId, TUserId userId, long securityVersion, - IReadOnlyDictionary? claimsSnapshot = null) + ClaimsSnapshot claimsSnapshot) { return new UAuthSessionChain( chainId, @@ -54,7 +54,25 @@ public static UAuthSessionChain Create( ); } - public UAuthSessionChain ActivateSession(AuthSessionId sessionId) + public ISessionChain AttachSession(AuthSessionId sessionId) + { + if (IsRevoked) + return this; + + return new UAuthSessionChain( + ChainId, + TenantId, + UserId, + RotationCount, // Unchanged on first attach + SecurityVersionAtCreation, + ClaimsSnapshot, + activeSessionId: sessionId, + isRevoked: false, + revokedAt: null + ); + } + + public ISessionChain RotateSession(AuthSessionId sessionId) { if (IsRevoked) return this; @@ -72,7 +90,7 @@ public UAuthSessionChain ActivateSession(AuthSessionId sessionId) ); } - public UAuthSessionChain Revoke(DateTime at) + public ISessionChain Revoke(DateTime at) { if (IsRevoked) return this; diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/IUser.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/IUser.cs similarity index 93% rename from src/CodeBeam.UltimateAuth.Core/Abstractions/IUser.cs rename to src/CodeBeam.UltimateAuth.Core/Domain/User/IUser.cs index b3fe970..5222ca9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/IUser.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/IUser.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Core.Abstractions +namespace CodeBeam.UltimateAuth.Core.Domain { /// /// Represents the minimal user abstraction required by UltimateAuth. diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserId.cs new file mode 100644 index 0000000..962885f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserId.cs @@ -0,0 +1,16 @@ +namespace CodeBeam.UltimateAuth.Core.Domain +{ + /// + /// Strongly typed identifier for a user. + /// Default user id implementation for UltimateAuth. + /// + public readonly record struct UserId(string Value) + { + public override string ToString() => Value; + + public static UserId New() => new(Guid.NewGuid().ToString("N")); + + public static implicit operator string(UserId id) => id.Value; + public static implicit operator UserId(string value) => new(value); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs new file mode 100644 index 0000000..2238697 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthChainException.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public abstract class UAuthChainException : UAuthDomainException + { + public ChainId ChainId { get; } + + protected UAuthChainException( + ChainId chainId, + string message) + : base(message) + { + ChainId = chainId; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthChainLinkMissingException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthChainLinkMissingException.cs new file mode 100644 index 0000000..68ce8bd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthChainLinkMissingException.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthSessionChainLinkMissingException : UAuthSessionException + { + public UAuthSessionChainLinkMissingException(AuthSessionId sessionId) + : base( + sessionId, + $"Session '{sessionId}' is not associated with any session chain.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs new file mode 100644 index 0000000..758b7ef --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainNotFoundException.cs @@ -0,0 +1,12 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthSessionChainNotFoundException : UAuthChainException + { + public UAuthSessionChainNotFoundException(ChainId chainId) + : base(chainId, $"Session chain '{chainId}' was not found.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs new file mode 100644 index 0000000..c755b93 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionChainRevokedException.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthSessionChainRevokedException : UAuthChainException + { + public ChainId ChainId { get; } + + public UAuthSessionChainRevokedException(ChainId chainId) + : base(chainId, $"Session chain '{chainId}' has been revoked.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs new file mode 100644 index 0000000..bb8660f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionDeviceMismatchException.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthSessionDeviceMismatchException : UAuthSessionException + { + public DeviceInfo Expected { get; } + public DeviceInfo Actual { get; } + + public UAuthSessionDeviceMismatchException( + AuthSessionId sessionId, + DeviceInfo expected, + DeviceInfo actual) + : base( + sessionId, + $"Session '{sessionId}' device mismatch detected.") + { + Expected = expected; + Actual = actual; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionExpiredException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionExpiredException.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionExpiredException.cs rename to src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionExpiredException.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs new file mode 100644 index 0000000..bd396bd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionInvalidStateException.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthSessionInvalidStateException : UAuthSessionException + { + public SessionState State { get; } + + public UAuthSessionInvalidStateException( + AuthSessionId sessionId, + SessionState state) + : base( + sessionId, + $"Session '{sessionId}' is in invalid state '{state}'.") + { + State = state; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionNotActiveException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotActiveException.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionNotActiveException.cs rename to src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotActiveException.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs new file mode 100644 index 0000000..8cc0a59 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionNotFoundException.cs @@ -0,0 +1,13 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthSessionNotFoundException + : UAuthSessionException + { + public UAuthSessionNotFoundException(AuthSessionId sessionId) + : base(sessionId, $"Session '{sessionId}' was not found.") + { + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRevokedException.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Errors/UAuthSessionRevokedException.cs rename to src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRevokedException.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs new file mode 100644 index 0000000..f1c8978 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionRootRevokedException.cs @@ -0,0 +1,13 @@ +namespace CodeBeam.UltimateAuth.Core.Errors +{ + public sealed class UAuthSessionRootRevokedException : Exception + { + public object UserId { get; } + + public UAuthSessionRootRevokedException(object userId) + : base("All sessions for the user have been revoked.") + { + UserId = userId; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs new file mode 100644 index 0000000..9ba17f1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Session/UAuthSessionSecurityMismatchException.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthSessionSecurityMismatchException : UAuthSessionException +{ + public long CurrentSecurityVersion { get; } + + public UAuthSessionSecurityMismatchException( + AuthSessionId sessionId, + long currentSecurityVersion) + : base( + sessionId, + $"Session '{sessionId}' is invalid due to security version mismatch.") + { + CurrentSecurityVersion = currentSecurityVersion; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSecurityVersionMismatchException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSecurityVersionMismatchException.cs deleted file mode 100644 index b312dde..0000000 --- a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthSecurityVersionMismatchException.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Errors -{ - /// - /// Represents a domain-level authentication failure caused by a mismatch - /// between the session's stored security version and the user's current - /// security version. - /// A mismatch indicates that a critical security event has occurred - /// after the session was created—such as a password reset, MFA reset, - /// account recovery, or other action requiring all prior sessions - /// to be invalidated. - /// - public sealed class UAuthSecurityVersionMismatchException : UAuthDomainException - { - /// - /// Gets the security version captured when the session was created. - /// - public long SessionVersion { get; } - - /// - /// Gets the user's current security version, which has increased - /// since the session was issued. - /// - public long UserVersion { get; } - - /// - /// Initializes a new instance of the class - /// using the session's stored version and the user's current version. - /// - /// The security version value stored in the session. - /// The user's current security version. - public UAuthSecurityVersionMismatchException(long sessionVersion, long userVersion) : base($"Security version mismatch. Session={sessionVersion}, User={userVersion}") - { - SessionVersion = sessionVersion; - UserVersion = userVersion; - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs index a8f872d..315d384 100644 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UltimateAuthServiceCollectionExtensions.cs @@ -1,9 +1,8 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Core.Utilities; +using CodeBeam.UltimateAuth.Core.Infrastructure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Core.Extensions diff --git a/src/CodeBeam.UltimateAuth.Core/Utilities/Base64Url.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs similarity index 96% rename from src/CodeBeam.UltimateAuth.Core/Utilities/Base64Url.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs index 8b120f0..48fb6c8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Utilities/Base64Url.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs @@ -1,4 +1,4 @@ -namespace CodeBeam.UltimateAuth.Core.Utilities +namespace CodeBeam.UltimateAuth.Core.Infrastructure { /// /// Provides Base64 URL-safe encoding and decoding utilities. diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/GuidUserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/GuidUserIdFactory.cs new file mode 100644 index 0000000..afe906b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/GuidUserIdFactory.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + public sealed class GuidUserIdFactory : IUserIdFactory + { + public Guid Create() => Guid.NewGuid(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Utilities/RandomIdGenerator.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/RandomIdGenerator.cs similarity index 97% rename from src/CodeBeam.UltimateAuth.Core/Utilities/RandomIdGenerator.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/RandomIdGenerator.cs index 5c63d26..b2faa23 100644 --- a/src/CodeBeam.UltimateAuth.Core/Utilities/RandomIdGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/RandomIdGenerator.cs @@ -1,6 +1,6 @@ using System.Security.Cryptography; -namespace CodeBeam.UltimateAuth.Core.Utilities +namespace CodeBeam.UltimateAuth.Core.Infrastructure { /// /// Provides cryptographically secure random ID generation. diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/StringUserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/StringUserIdFactory.cs new file mode 100644 index 0000000..a622edf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/StringUserIdFactory.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + public sealed class StringUserIdFactory : IUserIdFactory + { + public string Create() => Guid.NewGuid().ToString("N"); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs similarity index 84% rename from src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverter.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs index e0ebae5..01085ff 100644 --- a/src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverter.cs @@ -4,7 +4,7 @@ using System.Text; using System.Text.Json; -namespace CodeBeam.UltimateAuth.Core.Utilities +namespace CodeBeam.UltimateAuth.Core.Infrastructure { /// /// Default implementation of that provides @@ -71,6 +71,20 @@ public TUserId FromString(string value) }; } + public bool TryFromString(string value, out TUserId? id) + { + try + { + id = FromString(value); + return true; + } + catch + { + id = default; + return false; + } + } + /// /// Converts a UTF-8 encoded binary representation back into a user id. /// @@ -79,5 +93,19 @@ public TUserId FromString(string value) public TUserId FromBytes(byte[] binary) => FromString(Encoding.UTF8.GetString(binary)); + public bool TryFromBytes(byte[] binary, out TUserId? id) + { + try + { + id = FromBytes(binary); + return true; + } + catch + { + id = default; + return false; + } + } + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverterResolver.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs similarity index 97% rename from src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverterResolver.cs rename to src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs index 8e2f079..8ac22a1 100644 --- a/src/CodeBeam.UltimateAuth.Core/Utilities/UAuthUserIdConverterResolver.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using Microsoft.Extensions.DependencyInjection; -namespace CodeBeam.UltimateAuth.Core.Utilities +namespace CodeBeam.UltimateAuth.Core.Infrastructure { /// /// Resolves instances from the DI container. diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs new file mode 100644 index 0000000..7a14b54 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs @@ -0,0 +1,10 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + public sealed class UserIdFactory : IUserIdFactory + { + public UserId Create() => new UserId(Guid.NewGuid().ToString("N")); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs new file mode 100644 index 0000000..3f80324 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserRecord.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure +{ + public sealed class UserRecord + { + public required TUserId Id { get; init; } + public required string Username { get; init; } + public required string PasswordHash { get; init; } + public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; + public bool RequiresMfa { get; init; } + public bool IsActive { get; init; } = true; + public DateTime CreatedAt { get; init; } + public bool IsDeleted { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Models/SessionValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Models/SessionValidationResult.cs deleted file mode 100644 index 31560b1..0000000 --- a/src/CodeBeam.UltimateAuth.Core/Models/SessionValidationResult.cs +++ /dev/null @@ -1,35 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Core.Models -{ - /// - /// Represents the outcome of validating a session, including the resolved session, - /// its chain and root structures, and the computed validation state. - /// - /// - /// Session, Chain and Root may be null if validation fails or if the session - /// does not exist. State always indicates the final resolved status. - /// - public sealed class SessionValidationResult - { - /// - /// The resolved session instance, or null if the session was not found. - /// - public ISession? Session { get; init; } - - /// - /// The session chain that owns the session, or null if unavailable. - /// - public ISessionChain? Chain { get; init; } - - /// - /// The session root associated with the user, or null if unavailable. - /// - public ISessionRoot? Root { get; init; } - - /// - /// The final computed validation state for the session. - /// - public SessionState State { get; init; } - } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Enums/UAuthMode.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMode.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Enums/UAuthMode.cs rename to src/CodeBeam.UltimateAuth.Core/Options/UAuthMode.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs new file mode 100644 index 0000000..adb05d4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Abstractions +{ + /// + /// Resolves device and client metadata from the current HTTP context. + /// + public interface IDeviceResolver + { + DeviceInfo Resolve(HttpContext context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj index d4e9395..957c721 100644 --- a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj +++ b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj @@ -8,15 +8,19 @@ - + - + + + + + diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs index 2b94841..cb63cef 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/DefaultLoginEndpointHandler.cs @@ -1,12 +1,60 @@ -using Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.MultiTenancy; +using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Endpoints +public sealed class DefaultLoginEndpointHandler : ILoginEndpointHandler { - public class DefaultLoginEndpointHandler : ILoginEndpointHandler + private readonly IUAuthFlowService _flow; + private readonly IDeviceResolver _deviceResolver; + private readonly ITenantResolver _tenantResolver; + private readonly IClock _clock; + + public DefaultLoginEndpointHandler( + IUAuthFlowService flow, + IDeviceResolver deviceResolver, + ITenantResolver tenantResolver, + IClock clock) + { + _flow = flow; + _deviceResolver = deviceResolver; + _tenantResolver = tenantResolver; + _clock = clock; + } + + public async Task LoginAsync(HttpContext ctx) { - public Task LoginAsync(HttpContext ctx) + var request = await ctx.Request.ReadFromJsonAsync(); + if (request is null) + return Results.BadRequest("Invalid login request."); + + var tenantCtx = await _tenantResolver.ResolveAsync(ctx); + + var flowRequest = request with { - return Task.FromResult(Results.StatusCode(StatusCodes.Status501NotImplemented)); - } + TenantId = tenantCtx.TenantId, + At = _clock.UtcNow, + DeviceInfo = _deviceResolver.Resolve(ctx) + }; + + var result = await _flow.LoginAsync(flowRequest, ctx.RequestAborted); + + return result.Status switch + { + LoginStatus.Success => Results.Ok(new + { + sessionId = result.SessionId, + accessToken = result.AccessToken, + refreshToken = result.RefreshToken + }), + + LoginStatus.RequiresContinuation => Results.Accepted(null, result.Continuation), + + LoginStatus.Failed => Results.Unauthorized(), + + _ => Results.StatusCode(500) + }; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Events/.gitkeep b/src/CodeBeam.UltimateAuth.Server/Events/.gitkeep deleted file mode 100644 index 5f28270..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Events/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ClaimsSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ClaimsSnapshotExtensions.cs new file mode 100644 index 0000000..bfdbee3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ClaimsSnapshotExtensions.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Server.Extensions +{ + public static class ClaimsSnapshotExtensions + { + public static IReadOnlyCollection AsClaims( + this ClaimsSnapshot snapshot) + => snapshot.AsDictionary() + .Select(kv => new Claim(kv.Key, kv.Value)) + .ToArray(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs index 474f783..4d3063f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContextSessionExtensions.cs @@ -1,17 +1,14 @@ -using CodeBeam.UltimateAuth.Server.Middlewares; -using CodeBeam.UltimateAuth.Server.Sessions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Extensions { public static class HttpContextSessionExtensions { - public static SessionContext GetSessionContext( - this HttpContext context) + public static SessionContext GetSessionContext(this HttpContext context) { - if (context.Items.TryGetValue( - SessionResolutionMiddleware.SessionContextKey, - out var value) + if (context.Items.TryGetValue(SessionResolutionMiddleware.SessionContextKey, out var value) && value is SessionContext session) { return session; diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs index 8520014..c34701d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerServiceCollectionExtensions.cs @@ -1,10 +1,13 @@ -using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Endpoints; using CodeBeam.UltimateAuth.Server.Issuers; using CodeBeam.UltimateAuth.Server.MultiTenancy; using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Server.Users; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -84,6 +87,11 @@ private static IServiceCollection AddUltimateAuthServerInternal( services.TryAddScoped(); + services.AddScoped(typeof(IUAuthFlowService), typeof(UAuthFlowService<>)); + services.AddScoped(typeof(IUAuthSessionService<>), typeof(UAuthSessionService<>)); + services.AddScoped(typeof(IUAuthUserService<>), typeof(UAuthUserService<>)); + services.AddScoped(typeof(IUAuthTokenService<>), typeof(UAuthTokenService<>)); + // ----------------------------- // SESSION / TOKEN ISSUERS // ----------------------------- diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/BearerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/BearerSessionIdResolver.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Server/Sessions/BearerSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/BearerSessionIdResolver.cs index 49293e4..f2ffcec 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/BearerSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/BearerSessionIdResolver.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class BearerSessionIdResolver : ISessionIdResolver { diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/CompositeSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Server/Sessions/CompositeSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs index b69111b..dd5c6f6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/CompositeSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CompositeSessionIdResolver.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class CompositeSessionIdResolver : ISessionIdResolver { diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/CookieSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Server/Sessions/CookieSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs index cb33ac7..c7d533e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/CookieSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/CookieSessionIdResolver.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class CookieSessionIdResolver : ISessionIdResolver { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs new file mode 100644 index 0000000..fbfdcfd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DefaultUserAuthenticator.cs @@ -0,0 +1,40 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class DefaultUserAuthenticator : IUserAuthenticator + { + private readonly IUAuthUserStore _userStore; + private readonly IUAuthPasswordHasher _passwordHasher; + + public DefaultUserAuthenticator(IUAuthUserStore userStore, IUAuthPasswordHasher passwordHasher) + { + _userStore = userStore; + _passwordHasher = passwordHasher; + } + + public async Task> AuthenticateAsync( + string? tenantId, + string username, + string secret, + CancellationToken cancellationToken = default) + { + var user = await _userStore.FindByUsernameAsync( + tenantId, + username, + cancellationToken); + + if (user is null) + return UserAuthenticationResult.Fail(); + + if (!user.IsActive) + return UserAuthenticationResult.Fail(); + + if (!_passwordHasher.Verify(secret, user.PasswordHash)) + return UserAuthenticationResult.Fail(); + + return UserAuthenticationResult.Success(user.Id, user.Claims, user.RequiresMfa); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/HeaderSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Server/Sessions/HeaderSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs index aad25f4..ca6521e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/HeaderSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HeaderSessionIdResolver.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class HeaderSessionIdResolver : ISessionIdResolver { diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionIdResolver.cs similarity index 77% rename from src/CodeBeam.UltimateAuth.Server/Sessions/ISessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionIdResolver.cs index bddb506..46c063b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionIdResolver.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public interface ISessionIdResolver { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs new file mode 100644 index 0000000..b388f55 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ISessionOrchestrator.cs @@ -0,0 +1,30 @@ +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/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ITokenIssuer.cs new file mode 100644 index 0000000..803bb4b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ITokenIssuer.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + /// + /// Issues access and refresh tokens according to the active auth mode. + /// Does not perform persistence or validation. + /// + public interface ITokenIssuer + { + Task IssueAccessTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default); + Task IssueRefreshTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Users/IUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/IUserAccessor.cs similarity index 71% rename from src/CodeBeam.UltimateAuth.Server/Users/IUserAccessor.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/IUserAccessor.cs index 05a0e5a..87de8da 100644 --- a/src/CodeBeam.UltimateAuth.Server/Users/IUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/IUserAccessor.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Users +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public interface IUserAccessor { diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/QuerySessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs similarity index 92% rename from src/CodeBeam.UltimateAuth.Server/Sessions/QuerySessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs index 3019d8c..237a9b1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/QuerySessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/QuerySessionIdResolver.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class QuerySessionIdResolver : ISessionIdResolver { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs new file mode 100644 index 0000000..5a3b064 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class SystemClock : IClock + { + public DateTime UtcNow => DateTime.UtcNow; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs new file mode 100644 index 0000000..67e8ea6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/TokenIssuanceContext.cs @@ -0,0 +1,13 @@ +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed record TokenIssuanceContext + { + public string UserId { get; init; } = default!; + public string? TenantId { get; init; } + public IReadOnlyCollection Claims { get; init; } = Array.Empty(); + public string? SessionId { get; init; } + public DateTime IssuedAt { get; init; } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthDeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthDeviceResolver.cs new file mode 100644 index 0000000..15ef29a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthDeviceResolver.cs @@ -0,0 +1,49 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public sealed class DefaultDeviceResolver : IDeviceResolver + { + public DeviceInfo Resolve(HttpContext context) + { + var request = context.Request; + + return new DeviceInfo + { + DeviceId = ResolveDeviceId(context), + Platform = ResolvePlatform(request), + OperatingSystem = null, // optional UA parsing later + Browser = request.Headers.UserAgent.ToString(), + IpAddress = context.Connection.RemoteIpAddress?.ToString(), + UserAgent = request.Headers.UserAgent.ToString(), + IsTrusted = null + }; + } + + private static string ResolveDeviceId(HttpContext context) + { + if (context.Request.Headers.TryGetValue("X-Device-Id", out var header)) + return header.ToString(); + + if (context.Request.Cookies.TryGetValue("ua_device", out var cookie)) + return cookie; + + return "unknown"; + } + + private static string? ResolvePlatform(HttpRequest request) + { + var ua = request.Headers.UserAgent.ToString().ToLowerInvariant(); + + if (ua.Contains("android")) return "android"; + if (ua.Contains("iphone") || ua.Contains("ipad")) return "ios"; + if (ua.Contains("windows")) return "windows"; + if (ua.Contains("mac os")) return "macos"; + if (ua.Contains("linux")) return "linux"; + + return "web"; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionIdResolver.cs similarity index 96% rename from src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionIdResolver.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionIdResolver.cs index 75afe79..9219aaa 100644 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionIdResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionIdResolver.cs @@ -2,7 +2,7 @@ using CodeBeam.UltimateAuth.Server.Options; using Microsoft.Extensions.Options; -namespace CodeBeam.UltimateAuth.Server.Sessions +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class UAuthSessionIdResolver : ISessionIdResolver { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs new file mode 100644 index 0000000..7bcd741 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthSessionOrchestrator.cs @@ -0,0 +1,337 @@ +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/Users/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs similarity index 85% rename from src/CodeBeam.UltimateAuth.Server/Users/UAuthUserAccessor.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs index 0769479..d9c85bb 100644 --- a/src/CodeBeam.UltimateAuth.Server/Users/UAuthUserAccessor.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserAccessor.cs @@ -1,19 +1,19 @@ using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Middlewares; using Microsoft.AspNetCore.Http; -namespace CodeBeam.UltimateAuth.Server.Users +namespace CodeBeam.UltimateAuth.Server.Infrastructure { public sealed class UAuthUserAccessor : IUserAccessor { private readonly ISessionStore _sessionStore; - private readonly IUserStore _userStore; + private readonly IUAuthUserStore _userStore; public UAuthUserAccessor( ISessionStore sessionStore, - IUserStore userStore) + IUAuthUserStore userStore) { _sessionStore = sessionStore; _userStore = userStore; @@ -43,7 +43,7 @@ public async Task ResolveAsync(HttpContext context) } // 👤 Load user - var user = await _userStore.FindByIdAsync(session.UserId); + var user = await _userStore.FindByIdAsync(sessionCtx.TenantId, session.UserId); if (user is null) { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserId.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserId.cs new file mode 100644 index 0000000..d42d35b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UAuthUserId.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.Server.Infrastructure +{ + public readonly record struct UAuthUserId(Guid Value) + { + public override string ToString() => Value.ToString("N"); + + public static UAuthUserId New() => new(Guid.NewGuid()); + + public static implicit operator Guid(UAuthUserId id) => id.Value; + public static implicit operator UAuthUserId(Guid value) => new(value); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Internal/.gitkeep b/src/CodeBeam.UltimateAuth.Server/Internal/.gitkeep deleted file mode 100644 index 5f28270..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Internal/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs index 3a224fa..201d6b5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthSessionIssuer.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Domain.Session; using CodeBeam.UltimateAuth.Server.Options; @@ -23,10 +23,11 @@ public UAuthSessionIssuer(IOpaqueTokenGenerator opaqueGenerator, IOptions> IssueAsync( - SessionIssueContext context, - UAuthSessionChain chain, - CancellationToken cancellationToken = default) + AuthenticatedSessionContext context, + ISessionChain chain, + CancellationToken cancellationToken = default) { if (_options.Mode == UAuthMode.PureJwt) { @@ -43,9 +44,7 @@ public Task> IssueAsync( context.Now.Add(_options.Session.MaxLifetime.Value); if (absoluteExpiry < expiresAt) - { expiresAt = absoluteExpiry; - } } var session = UAuthSession.Create( @@ -54,9 +53,9 @@ public Task> IssueAsync( userId: context.UserId, now: context.Now, expiresAt: expiresAt, - securityVersion: context.SecurityVersion, - device: context.Device, - metadata: SessionMetadata.Empty + claims: context.Claims, + device: context.DeviceInfo, + metadata: context.Metadata ); return Task.FromResult(new IssuedSession diff --git a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs index 46d6e3f..e965827 100644 --- a/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Issuers/UAuthTokenIssuer.cs @@ -1,7 +1,8 @@ using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contexts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using Microsoft.Extensions.Options; using System.Security.Claims; @@ -28,7 +29,7 @@ public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerato _options = options.Value; } - public Task IssueAccessTokenAsync(TokenIssueContext context, CancellationToken cancellationToken = default) + public Task IssueAccessTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; var expires = now.Add(_options.Tokens.AccessTokenLifetime); @@ -50,10 +51,10 @@ UAuthMode.SemiHybrid or }; } - public Task IssueRefreshTokenAsync(TokenIssueContext context, CancellationToken cancellationToken = default) + public Task IssueRefreshTokenAsync(TokenIssuanceContext context, CancellationToken cancellationToken = default) { if (_options.Mode == UAuthMode.PureOpaque) - return Task.FromResult(null); + return Task.FromResult(null); var now = DateTimeOffset.UtcNow; var expires = now.Add(_options.Tokens.RefreshTokenLifetime); @@ -61,7 +62,7 @@ UAuthMode.SemiHybrid or string token = _opaqueGenerator.Generate(); string hash = _tokenHasher.Hash(token); - return Task.FromResult(new IssuedRefreshToken + return Task.FromResult(new RefreshToken { Token = token, TokenHash = hash, @@ -69,20 +70,20 @@ UAuthMode.SemiHybrid or }); } - private IssuedAccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessionId) + private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessionId) { string token = _opaqueGenerator.Generate(); - return new IssuedAccessToken + return new AccessToken { Token = token, - TokenType = "opaque", + Type = TokenType.Opaque, ExpiresAt = expires, SessionId = sessionId }; } - private IssuedAccessToken IssueJwtAccessToken(TokenIssueContext context, DateTimeOffset expires) + private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, DateTimeOffset expires) { var claims = new List { @@ -114,10 +115,10 @@ private IssuedAccessToken IssueJwtAccessToken(TokenIssueContext context, DateTim string jwt = _jwtGenerator.CreateToken(descriptor); - return new IssuedAccessToken + return new AccessToken { Token = jwt, - TokenType = "jwt", + Type = TokenType.Jwt, ExpiresAt = expires, SessionId = context.SessionId }; diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs index 3ec6a3b..2f5380e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs @@ -1,5 +1,6 @@ -using CodeBeam.UltimateAuth.Server.Extensions; -using CodeBeam.UltimateAuth.Server.Sessions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Middlewares diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs index aa87a46..079fbc8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs @@ -1,4 +1,4 @@ -using CodeBeam.UltimateAuth.Server.Users; +using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Middlewares diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/DomainTenantAdapter.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/DomainTenantAdapter.cs deleted file mode 100644 index 88b3c74..0000000 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/DomainTenantAdapter.cs +++ /dev/null @@ -1,34 +0,0 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Core.Options; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.MultiTenancy -{ - public sealed class DomainTenantAdapter : ITenantResolver - { - private readonly ITenantIdResolver _coreResolver; - private readonly UAuthMultiTenantOptions _options; - - public DomainTenantAdapter( - HostTenantResolver coreResolver, - UAuthMultiTenantOptions options) - { - _coreResolver = coreResolver; - _options = options; - } - - public async Task ResolveAsync(HttpContext ctx) - { - if (!_options.Enabled) - return UAuthTenantContext.NotResolved(); - - var resolutionContext = TenantResolutionContextFactory.FromHttpContext(ctx); - var tenantId = await _coreResolver.ResolveTenantIdAsync(resolutionContext); - - if (tenantId is null) - return UAuthTenantContext.NotResolved(); - - return UAuthTenantContextFactory.Create(tenantId, _options); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/HeaderTenantAdapter.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/HeaderTenantAdapter.cs deleted file mode 100644 index 8250cdb..0000000 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/HeaderTenantAdapter.cs +++ /dev/null @@ -1,34 +0,0 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Core.Options; -using Microsoft.AspNetCore.Http; - -namespace CodeBeam.UltimateAuth.Server.MultiTenancy -{ - public sealed class HeaderTenantAdapter : ITenantResolver - { - private readonly ITenantIdResolver _coreResolver; - private readonly UAuthMultiTenantOptions _options; - - public HeaderTenantAdapter( - HeaderTenantResolver coreResolver, - UAuthMultiTenantOptions options) - { - _coreResolver = coreResolver; - _options = options; - } - - public async Task ResolveAsync(HttpContext ctx) - { - if (!_options.Enabled) - return UAuthTenantContext.NotResolved(); - - var resolutionContext = TenantResolutionContextFactory.FromHttpContext(ctx); - - var tenantId = await _coreResolver.ResolveTenantIdAsync(resolutionContext); - if (tenantId is null) - return UAuthTenantContext.NotResolved(); - - return UAuthTenantContextFactory.Create(tenantId, _options); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/RouteTenantAdapter.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/RouteTenantAdapter.cs deleted file mode 100644 index e69719a..0000000 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/RouteTenantAdapter.cs +++ /dev/null @@ -1,32 +0,0 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.MultiTenancy; -using Microsoft.AspNetCore.Http; - -public sealed class RouteTenantAdapter : ITenantResolver -{ - private readonly ITenantIdResolver _coreResolver; - private readonly UAuthMultiTenantOptions _options; - - public RouteTenantAdapter( - PathTenantResolver coreResolver, - UAuthMultiTenantOptions options) - { - _coreResolver = coreResolver; - _options = options; - } - - public async Task ResolveAsync(HttpContext ctx) - { - if (!_options.Enabled) - return UAuthTenantContext.NotResolved(); - - var resolutionContext = TenantResolutionContextFactory.FromHttpContext(ctx); - var tenantId = await _coreResolver.ResolveTenantIdAsync(resolutionContext); - - if (tenantId is null) - return UAuthTenantContext.NotResolved(); - - return UAuthTenantContextFactory.Create(tenantId, _options); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/.gitkeep b/src/CodeBeam.UltimateAuth.Server/Services/.gitkeep deleted file mode 100644 index 5f28270..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs new file mode 100644 index 0000000..084e3b0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -0,0 +1,149 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Services +{ + internal sealed class UAuthFlowService : IUAuthFlowService + { + private readonly IUAuthUserService _users; + private readonly IUAuthSessionService _sessions; + private readonly IUAuthTokenService _tokens; + + public UAuthFlowService( + IUAuthUserService users, + IUAuthSessionService sessions, + IUAuthTokenService tokens) + { + _users = users; + _sessions = sessions; + _tokens = tokens; + } + + public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task ConsumePkceAsync(PkceConsumeRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task CreatePkceChallengeAsync(PkceCreateRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public async Task LoginAsync(LoginRequest request, CancellationToken ct = default) + { + var now = request.At ?? DateTime.UtcNow; + var device = request.DeviceInfo ?? DeviceInfo.Unknown; + + var authResult = await _users.AuthenticateAsync(request.TenantId, request.Identifier, request.Secret, ct); + + if (!authResult.Succeeded) + { + return LoginResult.Failed(); + } + + var sessionResult = await _sessions.IssueSessionAfterAuthenticationAsync(request.TenantId, + new AuthenticatedSessionContext + { + TenantId = request.TenantId, + UserId = authResult.UserId!, + Now = now, + DeviceInfo = device, + Claims = authResult.Claims, + ChainId = request.ChainId + }); + + AuthTokens? tokens = null; + + if (request.RequestTokens) + { + tokens = await _tokens.CreateTokensAsync( + new TokenIssueContext + { + TenantId = request.TenantId, + Session = sessionResult.Session, + Now = now + }, + ct); + } + + return LoginResult.Success(sessionResult.Session.SessionId, tokens); + } + + public async Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) + { + var at = request.At ?? DateTime.UtcNow; + await _sessions.RevokeSessionAsync(request.TenantId, request.SessionId, at); + } + + public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default) + { + var at = request.At ?? DateTime.UtcNow; + + if (request.CurrentSessionId is null) + 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); + + if (validation.IsValid || + validation.Session is null) + throw new InvalidOperationException("Current session is not valid."); + + var userId = validation.Session.UserId; + + ChainId? currentChainId = null; + + if (request.ExceptCurrent) + { + if (request.CurrentSessionId is null) + throw new InvalidOperationException("CurrentSessionId must be provided when ExceptCurrent is true."); + + currentChainId = await _sessions.ResolveChainIdAsync( + request.TenantId, + currentSessionId); + + if (currentChainId is null) + throw new InvalidOperationException("Current session chain could not be resolved."); + } + + await _sessions.RevokeAllChainsAsync(request.TenantId, userId, exceptChainId: currentChainId, at); + } + + public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task RefreshSessionAsync(SessionRefreshRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task VerifyPkceAsync(PkceVerifyRequest request, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs new file mode 100644 index 0000000..43cdf35 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionService.cs @@ -0,0 +1,125 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Server.Services +{ + internal sealed class UAuthSessionService : IUAuthSessionService + { + private readonly ISessionOrchestrator _orchestrator; + + public UAuthSessionService(ISessionOrchestrator orchestrator) + { + _orchestrator = orchestrator; + } + + public Task> ValidateSessionAsync( + string? tenantId, + AuthSessionId sessionId, + DateTime now) + { + var context = new SessionValidationContext() + { + TenantId = tenantId, + Now = now + }; + + return _orchestrator.ValidateSessionAsync(context); + } + + public Task>> GetChainsAsync( + string? tenantId, + TUserId userId) + => _orchestrator.GetChainsAsync( + tenantId, + userId); + + public Task>> GetSessionsAsync( + string? tenantId, + ChainId chainId) + => _orchestrator.GetSessionsAsync( + tenantId, + chainId); + + public Task?> GetSessionAsync( + string? tenantId, + AuthSessionId sessionId) + => _orchestrator.GetSessionAsync( + tenantId, + sessionId); + + public Task RevokeSessionAsync( + string? tenantId, + AuthSessionId sessionId, + DateTime at) + => _orchestrator.RevokeSessionAsync( + tenantId, + sessionId, + at); + + public Task ResolveChainIdAsync( + string? tenantId, + AuthSessionId sessionId) + => _orchestrator.ResolveChainIdAsync(tenantId, sessionId); + + public Task RevokeAllChainsAsync( + string? tenantId, + TUserId userId, + ChainId? exceptChainId, + DateTime at) + => _orchestrator.RevokeAllChainsAsync( + tenantId, + userId, + exceptChainId, + at); + + public Task RevokeChainAsync( + string? tenantId, + ChainId chainId, + DateTime at) + => _orchestrator.RevokeChainAsync( + tenantId, + chainId, + at); + + public Task RevokeRootAsync( + string? tenantId, + TUserId userId, + DateTime at) + => _orchestrator.RevokeRootAsync( + tenantId, + userId, + at); + + public Task?> GetCurrentSessionAsync(string? tenantId, AuthSessionId sessionId) + { + // TODO: Implement this method + throw new NotImplementedException(); + } + + public Task> IssueSessionAfterAuthenticationAsync( + string? tenantId, + AuthenticatedSessionContext context, + CancellationToken cancellationToken = default) + { + if (context.UserId is null) + throw new InvalidOperationException( + "Authenticated session context requires a valid user id."); + + // 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.CreateLoginSessionAsync(issueContext); + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs new file mode 100644 index 0000000..6d35fbf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenService.cs @@ -0,0 +1,62 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Server.Services +{ + internal sealed class UAuthTokenService : IUAuthTokenService + { + private readonly ITokenIssuer _issuer; + private readonly ITokenValidator _validator; + private readonly IUserIdConverter _userIdConverter; + + public UAuthTokenService(ITokenIssuer issuer, ITokenValidator validator, IUserIdConverterResolver converterResolver) + { + _issuer = issuer; + _validator = validator; + _userIdConverter = converterResolver.GetConverter(); + } + + public async Task CreateTokensAsync( + TokenIssueContext context, + CancellationToken ct = default) + { + var issuerCtx = ToIssuerContext(context); + + var access = await _issuer.IssueAccessTokenAsync(issuerCtx, ct); + var refresh = await _issuer.IssueRefreshTokenAsync(issuerCtx, ct); + + return new AuthTokens + { + AccessToken = access, + RefreshToken = refresh + }; + } + + public async Task RefreshAsync( + TokenRefreshContext context, + CancellationToken ct = default) + { + throw new NotImplementedException("Refresh flow will be implemented after refresh-token store & validation."); + } + + public async Task> ValidateAsync( + string token, + TokenType type, + CancellationToken ct = default) + => await _validator.ValidateAsync(token, type, ct); + + private TokenIssuanceContext ToIssuerContext(TokenIssueContext src) + { + return new TokenIssuanceContext + { + UserId = _userIdConverter.ToString(src.Session.UserId), + TenantId = src.TenantId, + SessionId = src.Session.SessionId, + Claims = src.Session.Claims.AsClaims() + }; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenValidator.cs new file mode 100644 index 0000000..d48cba0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthTokenValidator.cs @@ -0,0 +1,165 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Server.Services +{ + internal sealed class UAuthTokenValidator : ITokenValidator + { + private readonly IOpaqueTokenStore _opaqueStore; + private readonly JsonWebTokenHandler _jwtHandler; + private readonly TokenValidationParameters _jwtParameters; + private readonly IUserIdConverterResolver _converters; + private readonly UAuthServerOptions _options; + private readonly ITokenHasher _tokenHasher; + + public UAuthTokenValidator( + IOpaqueTokenStore opaqueStore, + TokenValidationParameters jwtParameters, + IUserIdConverterResolver converters, + IOptions options, + ITokenHasher tokenHasher) + { + _opaqueStore = opaqueStore; + _jwtHandler = new JsonWebTokenHandler(); + _jwtParameters = jwtParameters; + _converters = converters; + _options = options.Value; + _tokenHasher = tokenHasher; + } + + public async Task> ValidateAsync( + string token, + TokenType type, + CancellationToken ct = default) + { + return type switch + { + TokenType.Jwt => await ValidateJwt(token), + TokenType.Opaque => await ValidateOpaqueAsync(token, ct), + _ => TokenValidationResult.Invalid(TokenType.Unknown, TokenInvalidReason.Unknown) + }; + } + + // ---------------- JWT ---------------- + + private async Task> ValidateJwt(string token) + { + var result = await _jwtHandler.ValidateTokenAsync(token, _jwtParameters); + + if (!result.IsValid) + { + return TokenValidationResult.Invalid(TokenType.Jwt, MapJwtError(result.Exception)); + } + + var jwt = (JsonWebToken)result.SecurityToken; + var claims = jwt.Claims.ToArray(); + + var converter = _converters.GetConverter(); + + var userIdString = jwt.GetClaim(ClaimTypes.NameIdentifier)?.Value ?? jwt.GetClaim("sub")?.Value; + if (string.IsNullOrWhiteSpace(userIdString)) + { + return TokenValidationResult.Invalid(TokenType.Jwt, TokenInvalidReason.MissingSubject); + } + + TUserId userId; + try + { + userId = converter.FromString(userIdString); + } + catch + { + return TokenValidationResult.Invalid(TokenType.Jwt, TokenInvalidReason.Malformed); + } + + var tenantId = jwt.GetClaim("tenant")?.Value ?? jwt.GetClaim("tid")?.Value; + AuthSessionId? sessionId = null; + var sid = jwt.GetClaim("sid")?.Value; + if (!string.IsNullOrWhiteSpace(sid)) + { + sessionId = new AuthSessionId(sid); + } + + return TokenValidationResult.Valid( + type: TokenType.Jwt, + tenantId: tenantId, + userId, + sessionId: sessionId, + claims: claims, + expiresAt: jwt.ValidTo); + } + + + // ---------------- OPAQUE ---------------- + + private async Task> ValidateOpaqueAsync(string token, CancellationToken ct) + { + var hash = _tokenHasher.Hash(token); + + var record = await _opaqueStore.FindByHashAsync(hash, ct); + if (record is null) + { + return TokenValidationResult.Invalid( + TokenType.Opaque, + TokenInvalidReason.Invalid); + } + + var now = DateTimeOffset.UtcNow; + if (record.ExpiresAt <= now) + { + return TokenValidationResult.Invalid( + TokenType.Opaque, + TokenInvalidReason.Expired); + } + + if (record.IsRevoked) + { + return TokenValidationResult.Invalid( + TokenType.Opaque, + TokenInvalidReason.Revoked); + } + + var converter = _converters.GetConverter(); + + TUserId userId; + try + { + userId = converter.FromString(record.UserId); + } + catch + { + return TokenValidationResult.Invalid( + TokenType.Opaque, + TokenInvalidReason.Invalid); + } + + return TokenValidationResult.Valid( + TokenType.Opaque, + record.TenantId, + userId, + record.SessionId, + record.Claims, + record.ExpiresAt.UtcDateTime); + } + + private static TokenInvalidReason MapJwtError(Exception? ex) + { + return ex switch + { + SecurityTokenExpiredException => TokenInvalidReason.Expired, + SecurityTokenInvalidSignatureException => TokenInvalidReason.SignatureInvalid, + SecurityTokenInvalidAudienceException => TokenInvalidReason.AudienceMismatch, + SecurityTokenInvalidIssuerException => TokenInvalidReason.IssuerMismatch, + _ => TokenInvalidReason.Invalid + }; + } + + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs new file mode 100644 index 0000000..bb02e0b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthUserService.cs @@ -0,0 +1,89 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Server.Users; + +internal sealed class UAuthUserService : IUAuthUserService +{ + private readonly IUAuthUserStore _userStore; + private readonly IUAuthPasswordHasher _passwordHasher; + private readonly IUserIdFactory _userIdFactory; + private readonly IUserAuthenticator _authenticator; + + public UAuthUserService( + IUAuthUserStore userStore, + IUAuthPasswordHasher passwordHasher, + IUserIdFactory userIdFactory, + IUserAuthenticator authenticator) + { + _userStore = userStore; + _passwordHasher = passwordHasher; + _userIdFactory = userIdFactory; + _authenticator = authenticator; + } + + public async Task RegisterAsync( + RegisterUserRequest request, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(request.Identifier)) + throw new ArgumentException("Username is required."); + + if (string.IsNullOrWhiteSpace(request.Password)) + throw new ArgumentException("Password is required."); + + if (await _userStore.ExistsByUsernameAsync(request.Identifier, ct)) + throw new InvalidOperationException("User already exists."); + + var hash = _passwordHasher.Hash(request.Password); + + var userId = _userIdFactory.Create(); + + await _userStore.CreateAsync( + new UserRecord + { + Id = userId, + Username = request.Identifier, + PasswordHash = hash, + CreatedAt = DateTime.UtcNow + }, + ct); + + return userId; + } + + public async Task ValidateCredentialsAsync( + ValidateCredentialsRequest request, + CancellationToken ct = default) + { + var user = await _userStore.FindByUsernameAsync(request.TenantId, request.Identifier, ct); + if (user is null) + return false; + + return _passwordHasher.Verify( + request.Password, + user.PasswordHash); + } + + public async Task DeleteAsync( + TUserId userId, + CancellationToken ct = default) + { + await _userStore.DeleteAsync(userId, ct); + } + + public async Task> AuthenticateAsync( + string? tenantId, + string identifier, + string secret, + CancellationToken cancellationToken = default) + { + return await _authenticator.AuthenticateAsync( + tenantId, + identifier, + secret, + cancellationToken); + } +} + diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionOrchestrator.cs deleted file mode 100644 index 358b8fd..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/ISessionOrchestrator.cs +++ /dev/null @@ -1,34 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contexts; -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Server.Sessions -{ - /// - /// Orchestrates session, chain, and root lifecycles - /// according to UltimateAuth security rules. - /// - public interface ISessionOrchestrator - { - /// - /// Creates a new login session (initial authentication). - /// - Task> CreateLoginSessionAsync( - SessionIssueContext context); - - /// - /// Revokes a single session. - /// - Task RevokeSessionAsync( - string? tenantId, - AuthSessionId sessionId, - DateTime at); - - /// - /// Revokes all sessions of a user (global logout). - /// - Task RevokeAllSessionsAsync( - string? tenantId, - TUserId userId, - DateTime at); - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionOrchestrator.cs deleted file mode 100644 index d16e03f..0000000 --- a/src/CodeBeam.UltimateAuth.Server/Sessions/UAuthSessionOrchestrator.cs +++ /dev/null @@ -1,235 +0,0 @@ -using CodeBeam.UltimateAuth.Core; -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contexts; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Issuers; -using CodeBeam.UltimateAuth.Server.Options; - -namespace CodeBeam.UltimateAuth.Server.Sessions -{ - /// - /// 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(SessionIssueContext context) - { - var kernel = _factory.Create(context.TenantId); - - // 1️⃣ Load or create root - 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 InvalidOperationException( - "User session root is revoked."); - } - - // 2️⃣ Load or create chain (interface → concrete) - ISessionChain? loadedChain = null; - - if (context.ChainId is not null) - { - loadedChain = await kernel.GetChainAsync( - context.TenantId, - context.ChainId.Value); - } - - if (loadedChain is not null && loadedChain.IsRevoked) - { - throw new InvalidOperationException( - "Session chain is revoked."); - } - - UAuthSessionChain chain; - - if (loadedChain is null) - { - chain = UAuthSessionChain.Create( - ChainId.New(), - context.TenantId, - context.UserId, - root.SecurityVersion, - context.ClaimsSnapshot); - } - else if (loadedChain is UAuthSessionChain concreteChain) - { - chain = concreteChain; - } - else - { - throw new InvalidOperationException( - "Unsupported ISessionChain implementation. " + - "UltimateAuth requires SessionChain."); - } - - // TODO: Add cancellation token support - var issuedSession = await _sessionIssuer.IssueAsync( - context, - chain); - - // 4️⃣ Persist session - await kernel.SaveSessionAsync( - context.TenantId, - issuedSession.Session); - - // 5️⃣ Update & persist chain - var updatedChain = chain.ActivateSession( - issuedSession.Session.SessionId); - - await kernel.SaveChainAsync( - context.TenantId, - updatedChain); - - // 6️⃣ Persist root (idempotent) - await kernel.SaveSessionRootAsync( - context.TenantId, - root); - - return issuedSession; - } - - public async Task> RotateSessionAsync(string? tenantId, AuthSessionId currentSessionId, SessionIssueContext context) - { - if (_serverOptions.Mode == UAuthMode.PureJwt) - throw new InvalidOperationException( - "Session rotation is not available in PureJwt mode."); - - var kernel = _factory.Create(tenantId); - - // 1️⃣ Load current session - var currentSession = await kernel.GetSessionAsync( - tenantId, - currentSessionId); - - if (currentSession is null) - throw new InvalidOperationException("Session not found."); - - if (currentSession.IsRevoked) - throw new InvalidOperationException("Session is revoked."); - - if (currentSession.GetState(context.Now) != SessionState.Active) - throw new InvalidOperationException("Session is not active."); - - // 2️⃣ Load chain id - var chainId = await kernel.GetChainIdBySessionAsync( - tenantId, - currentSessionId); - - if (chainId is null) - throw new InvalidOperationException("Session chain not found."); - - // 3️⃣ Load chain - var loadedChain = await kernel.GetChainAsync( - tenantId, - chainId.Value); - - if (loadedChain is null || loadedChain.IsRevoked) - throw new InvalidOperationException("Session chain is revoked."); - - if (loadedChain is not UAuthSessionChain chain) - throw new InvalidOperationException( - "Unsupported ISessionChain implementation."); - - // 4️⃣ Load root - var root = await kernel.GetSessionRootAsync( - tenantId, - context.UserId); - - if (root is null || root.IsRevoked) - throw new InvalidOperationException("Session root is revoked."); - - // 5️⃣ Security version check - if (currentSession.SecurityVersionAtCreation != root.SecurityVersion) - throw new InvalidOperationException( - "Session security version mismatch."); - - // TODO: Add cancellation token support - var issuedSession = await _sessionIssuer.IssueAsync( - context, - chain); - - // 7️⃣ Persist new session - await kernel.SaveSessionAsync( - tenantId, - issuedSession.Session); - - // 8️⃣ Revoke old session - await kernel.RevokeSessionAsync( - tenantId, - currentSessionId, - context.Now); - - // 9️⃣ Activate new session in chain - var updatedChain = chain.ActivateSession( - issuedSession.Session.SessionId); - - await kernel.SaveChainAsync( - tenantId, - updatedChain); - - // 🔟 Root persistence (idempotent) - await kernel.SaveSessionRootAsync( - tenantId, - root); - - return issuedSession; - } - - public Task?> GetSessionAsync( - string? tenantId, - AuthSessionId sessionId) - { - var kernel = _factory.Create(tenantId); - return kernel.GetSessionAsync(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); - } - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Stores/AspNetIdentityUserStore.cs b/src/CodeBeam.UltimateAuth.Server/Stores/AspNetIdentityUserStore.cs new file mode 100644 index 0000000..ac2fc6f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Stores/AspNetIdentityUserStore.cs @@ -0,0 +1,38 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CodeBeam.UltimateAuth.Server.Stores +{ + public sealed class AspNetIdentityUserStore // : IUAuthUserStore + { + //private readonly UserManager _users; + + //public AspNetIdentityUserStore(UserManager users) + //{ + // _users = users; + //} + + //public async Task?> FindByUsernameAsync( + // string? tenantId, + // string username, + // CancellationToken cancellationToken = default) + //{ + // var user = await _users.FindByNameAsync(username); + // if (user is null) + // return null; + + // var claims = await _users.GetClaimsAsync(user); + + // return new UAuthUserRecord + // { + // UserId = user.Id, + // Username = user.UserName!, + // PasswordHash = user.PasswordHash!, + // Claims = ClaimsSnapshot.From( + // claims.Select(c => (c.Type, c.Value)).ToArray()) + // }; + //} + } + +}